diff --git a/.agents/skills/impeccable/scripts/context-signals.mjs b/.agents/skills/impeccable/scripts/context-signals.mjs
index c03843812..f631d7561 100644
--- a/.agents/skills/impeccable/scripts/context-signals.mjs
+++ b/.agents/skills/impeccable/scripts/context-signals.mjs
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
diff --git a/.agents/skills/impeccable/scripts/critique-storage.mjs b/.agents/skills/impeccable/scripts/critique-storage.mjs
index eba12383e..645628c8c 100644
--- a/.agents/skills/impeccable/scripts/critique-storage.mjs
+++ b/.agents/skills/impeccable/scripts/critique-storage.mjs
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
diff --git a/.agents/skills/impeccable/scripts/design-parser.mjs b/.agents/skills/impeccable/scripts/lib/design-parser.mjs
similarity index 100%
rename from .agents/skills/impeccable/scripts/design-parser.mjs
rename to .agents/skills/impeccable/scripts/lib/design-parser.mjs
diff --git a/.agents/skills/impeccable/scripts/impeccable-paths.mjs b/.agents/skills/impeccable/scripts/lib/impeccable-paths.mjs
similarity index 100%
rename from .agents/skills/impeccable/scripts/impeccable-paths.mjs
rename to .agents/skills/impeccable/scripts/lib/impeccable-paths.mjs
diff --git a/.agents/skills/impeccable/scripts/is-generated.mjs b/.agents/skills/impeccable/scripts/lib/is-generated.mjs
similarity index 100%
rename from .agents/skills/impeccable/scripts/is-generated.mjs
rename to .agents/skills/impeccable/scripts/lib/is-generated.mjs
diff --git a/.agents/skills/impeccable/scripts/live-accept.mjs b/.agents/skills/impeccable/scripts/live-accept.mjs
index 5e8687187..278dfbb2e 100644
--- a/.agents/skills/impeccable/scripts/live-accept.mjs
+++ b/.agents/skills/impeccable/scripts/live-accept.mjs
@@ -15,14 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.agents/skills/impeccable/scripts/live-browser.js b/.agents/skills/impeccable/scripts/live-browser.js
index 0a533945f..9c4b6c21e 100644
--- a/.agents/skills/impeccable/scripts/live-browser.js
+++ b/.agents/skills/impeccable/scripts/live-browser.js
@@ -82,7 +82,7 @@
]);
// Command vocabulary (values + labels + icons) comes from the canonical source,
- // skill/scripts/live-vocabulary.mjs, which live-server.mjs serializes into
+ // skill/scripts/live/vocabulary.mjs, which live-server.mjs serializes into
// window.__IMPECCABLE_VOCAB__ when it serves /live.js (same injection path as
// the token/port above, so it is always present here). The icons stack above
// each chip label and recolor to C.brand when selected (strokes use
@@ -1216,7 +1216,7 @@
: (focused ? BP.accentSoft : BP.hairline);
}
- // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs)
+ // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs)
function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
diff --git a/.agents/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.agents/skills/impeccable/scripts/live-commit-manual-edits.mjs
index 44bc5ea4b..8bde46b50 100644
--- a/.agents/skills/impeccable/scripts/live-commit-manual-edits.mjs
+++ b/.agents/skills/impeccable/scripts/live-commit-manual-edits.mjs
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
-import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
-import { isGeneratedFile } from './is-generated.mjs';
+import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
diff --git a/.agents/skills/impeccable/scripts/live-complete.mjs b/.agents/skills/impeccable/scripts/live-complete.mjs
index 78155af85..1cc4e7543 100644
--- a/.agents/skills/impeccable/scripts/live-complete.mjs
+++ b/.agents/skills/impeccable/scripts/live-complete.mjs
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
diff --git a/.agents/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.agents/skills/impeccable/scripts/live-discard-manual-edits.mjs
index 435f22413..aa669fd20 100644
--- a/.agents/skills/impeccable/scripts/live-discard-manual-edits.mjs
+++ b/.agents/skills/impeccable/scripts/live-discard-manual-edits.mjs
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
-import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
+import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
diff --git a/.agents/skills/impeccable/scripts/live-inject.mjs b/.agents/skills/impeccable/scripts/live-inject.mjs
index 3a1f36e46..c006ad09e 100644
--- a/.agents/skills/impeccable/scripts/live-inject.mjs
+++ b/.agents/skills/impeccable/scripts/live-inject.mjs
@@ -16,12 +16,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { resolveLiveConfigPath } from './impeccable-paths.mjs';
+import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
-} from './live-sveltekit-adapter.mjs';
+} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
diff --git a/.agents/skills/impeccable/scripts/live-insert.mjs b/.agents/skills/impeccable/scripts/live-insert.mjs
index 0658e9914..0ed3cafea 100644
--- a/.agents/skills/impeccable/scripts/live-insert.mjs
+++ b/.agents/skills/impeccable/scripts/live-insert.mjs
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -25,7 +25,7 @@ import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
diff --git a/.agents/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.agents/skills/impeccable/scripts/live-manual-edit-evidence.mjs
index 860278b73..dd10e96dc 100644
--- a/.agents/skills/impeccable/scripts/live-manual-edit-evidence.mjs
+++ b/.agents/skills/impeccable/scripts/live-manual-edit-evidence.mjs
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
diff --git a/.agents/skills/impeccable/scripts/live-poll.mjs b/.agents/skills/impeccable/scripts/live-poll.mjs
index 1e1259fbc..b031055fe 100644
--- a/.agents/skills/impeccable/scripts/live-poll.mjs
+++ b/.agents/skills/impeccable/scripts/live-poll.mjs
@@ -12,8 +12,8 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
diff --git a/.agents/skills/impeccable/scripts/live-resume.mjs b/.agents/skills/impeccable/scripts/live-resume.mjs
index e54831f12..74284d48a 100644
--- a/.agents/skills/impeccable/scripts/live-resume.mjs
+++ b/.agents/skills/impeccable/scripts/live-resume.mjs
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs
index d9e06f449..187af0ac1 100644
--- a/.agents/skills/impeccable/scripts/live-server.mjs
+++ b/.agents/skills/impeccable/scripts/live-server.mjs
@@ -20,11 +20,11 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
-import { parseDesignMd } from './design-parser.mjs';
+import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { validateEvent } from './live-event-validation.mjs';
-import { LIVE_COMMANDS } from './live-vocabulary.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { validateEvent } from './live/event-validation.mjs';
+import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -33,20 +33,20 @@ import {
removeLiveServerInfo,
resolveDesignSidecarPath,
writeLiveServerInfo,
-} from './impeccable-paths.mjs';
+} from './lib/impeccable-paths.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
-} from './live-manual-edits-buffer.mjs';
+} from './live/manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
diff --git a/.agents/skills/impeccable/scripts/live-status.mjs b/.agents/skills/impeccable/scripts/live-status.mjs
index a7009cc29..f7e464999 100644
--- a/.agents/skills/impeccable/scripts/live-status.mjs
+++ b/.agents/skills/impeccable/scripts/live-status.mjs
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
diff --git a/.agents/skills/impeccable/scripts/live-wrap.mjs b/.agents/skills/impeccable/scripts/live-wrap.mjs
index a83cbcc56..c3f00f3a4 100644
--- a/.agents/skills/impeccable/scripts/live-wrap.mjs
+++ b/.agents/skills/impeccable/scripts/live-wrap.mjs
@@ -13,13 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.agents/skills/impeccable/scripts/live.mjs b/.agents/skills/impeccable/scripts/live.mjs
index 8acd300ed..0992da1bd 100644
--- a/.agents/skills/impeccable/scripts/live.mjs
+++ b/.agents/skills/impeccable/scripts/live.mjs
@@ -23,7 +23,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
diff --git a/.agents/skills/impeccable/scripts/live-completion.mjs b/.agents/skills/impeccable/scripts/live/completion.mjs
similarity index 100%
rename from .agents/skills/impeccable/scripts/live-completion.mjs
rename to .agents/skills/impeccable/scripts/live/completion.mjs
diff --git a/.agents/skills/impeccable/scripts/live-event-validation.mjs b/.agents/skills/impeccable/scripts/live/event-validation.mjs
similarity index 98%
rename from .agents/skills/impeccable/scripts/live-event-validation.mjs
rename to .agents/skills/impeccable/scripts/live/event-validation.mjs
index 15ae3265d..0897b1317 100644
--- a/.agents/skills/impeccable/scripts/live-event-validation.mjs
+++ b/.agents/skills/impeccable/scripts/live/event-validation.mjs
@@ -3,12 +3,12 @@
* Extracted for unit testing (insert mode rules).
*/
-import { canCreateInsert } from './live-insert-ui.mjs';
+import { canCreateInsert } from './insert-ui.mjs';
// The accepted visual action values come from the canonical vocabulary so the
// validator, the picker UI, and the marketing demo never drift. Imported (not
// just re-exported) so it is also in scope for the validators below.
-import { VISUAL_ACTIONS } from './live-vocabulary.mjs';
+import { VISUAL_ACTIONS } from './vocabulary.mjs';
export { VISUAL_ACTIONS };
const ID_PATTERN = /^[0-9a-f]{8}$/;
diff --git a/.agents/skills/impeccable/scripts/live-insert-ui.mjs b/.agents/skills/impeccable/scripts/live/insert-ui.mjs
similarity index 100%
rename from .agents/skills/impeccable/scripts/live-insert-ui.mjs
rename to .agents/skills/impeccable/scripts/live/insert-ui.mjs
diff --git a/.cursor/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.agents/skills/impeccable/scripts/live/manual-edits-buffer.mjs
similarity index 98%
rename from .cursor/skills/impeccable/scripts/live-manual-edits-buffer.mjs
rename to .agents/skills/impeccable/scripts/live/manual-edits-buffer.mjs
index 9e3dcf455..d96ebbe34 100644
--- a/.cursor/skills/impeccable/scripts/live-manual-edits-buffer.mjs
+++ b/.agents/skills/impeccable/scripts/live/manual-edits-buffer.mjs
@@ -12,7 +12,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { getLiveDir } from './impeccable-paths.mjs';
+import { getLiveDir } from '../lib/impeccable-paths.mjs';
const BUFFER_VERSION = 1;
const BUFFER_FILENAME = 'pending-manual-edits.json';
diff --git a/.cursor/skills/impeccable/scripts/live-session-store.mjs b/.agents/skills/impeccable/scripts/live/session-store.mjs
similarity index 99%
rename from .cursor/skills/impeccable/scripts/live-session-store.mjs
rename to .agents/skills/impeccable/scripts/live/session-store.mjs
index 5ec4d34d4..affba67c9 100644
--- a/.cursor/skills/impeccable/scripts/live-session-store.mjs
+++ b/.agents/skills/impeccable/scripts/live/session-store.mjs
@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
-import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
+import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
diff --git a/.agents/skills/impeccable/scripts/live-svelte-component.mjs b/.agents/skills/impeccable/scripts/live/svelte-component.mjs
similarity index 100%
rename from .agents/skills/impeccable/scripts/live-svelte-component.mjs
rename to .agents/skills/impeccable/scripts/live/svelte-component.mjs
diff --git a/.agents/skills/impeccable/scripts/live-sveltekit-adapter.mjs b/.agents/skills/impeccable/scripts/live/sveltekit-adapter.mjs
similarity index 100%
rename from .agents/skills/impeccable/scripts/live-sveltekit-adapter.mjs
rename to .agents/skills/impeccable/scripts/live/sveltekit-adapter.mjs
diff --git a/.agents/skills/impeccable/scripts/live-ui-core.mjs b/.agents/skills/impeccable/scripts/live/ui-core.mjs
similarity index 100%
rename from .agents/skills/impeccable/scripts/live-ui-core.mjs
rename to .agents/skills/impeccable/scripts/live/ui-core.mjs
diff --git a/.cursor/skills/impeccable/scripts/live-vocabulary.mjs b/.agents/skills/impeccable/scripts/live/vocabulary.mjs
similarity index 98%
rename from .cursor/skills/impeccable/scripts/live-vocabulary.mjs
rename to .agents/skills/impeccable/scripts/live/vocabulary.mjs
index 1123d41c4..5c7b0b713 100644
--- a/.cursor/skills/impeccable/scripts/live-vocabulary.mjs
+++ b/.agents/skills/impeccable/scripts/live/vocabulary.mjs
@@ -4,7 +4,7 @@
* 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/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
diff --git a/.claude/skills/impeccable/scripts/context-signals.mjs b/.claude/skills/impeccable/scripts/context-signals.mjs
index c03843812..f631d7561 100644
--- a/.claude/skills/impeccable/scripts/context-signals.mjs
+++ b/.claude/skills/impeccable/scripts/context-signals.mjs
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
diff --git a/.claude/skills/impeccable/scripts/critique-storage.mjs b/.claude/skills/impeccable/scripts/critique-storage.mjs
index eba12383e..645628c8c 100644
--- a/.claude/skills/impeccable/scripts/critique-storage.mjs
+++ b/.claude/skills/impeccable/scripts/critique-storage.mjs
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
diff --git a/.claude/skills/impeccable/scripts/design-parser.mjs b/.claude/skills/impeccable/scripts/lib/design-parser.mjs
similarity index 100%
rename from .claude/skills/impeccable/scripts/design-parser.mjs
rename to .claude/skills/impeccable/scripts/lib/design-parser.mjs
diff --git a/.claude/skills/impeccable/scripts/impeccable-paths.mjs b/.claude/skills/impeccable/scripts/lib/impeccable-paths.mjs
similarity index 100%
rename from .claude/skills/impeccable/scripts/impeccable-paths.mjs
rename to .claude/skills/impeccable/scripts/lib/impeccable-paths.mjs
diff --git a/.claude/skills/impeccable/scripts/is-generated.mjs b/.claude/skills/impeccable/scripts/lib/is-generated.mjs
similarity index 100%
rename from .claude/skills/impeccable/scripts/is-generated.mjs
rename to .claude/skills/impeccable/scripts/lib/is-generated.mjs
diff --git a/.claude/skills/impeccable/scripts/live-accept.mjs b/.claude/skills/impeccable/scripts/live-accept.mjs
index 5e8687187..278dfbb2e 100644
--- a/.claude/skills/impeccable/scripts/live-accept.mjs
+++ b/.claude/skills/impeccable/scripts/live-accept.mjs
@@ -15,14 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.claude/skills/impeccable/scripts/live-browser.js b/.claude/skills/impeccable/scripts/live-browser.js
index 0a533945f..9c4b6c21e 100644
--- a/.claude/skills/impeccable/scripts/live-browser.js
+++ b/.claude/skills/impeccable/scripts/live-browser.js
@@ -82,7 +82,7 @@
]);
// Command vocabulary (values + labels + icons) comes from the canonical source,
- // skill/scripts/live-vocabulary.mjs, which live-server.mjs serializes into
+ // skill/scripts/live/vocabulary.mjs, which live-server.mjs serializes into
// window.__IMPECCABLE_VOCAB__ when it serves /live.js (same injection path as
// the token/port above, so it is always present here). The icons stack above
// each chip label and recolor to C.brand when selected (strokes use
@@ -1216,7 +1216,7 @@
: (focused ? BP.accentSoft : BP.hairline);
}
- // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs)
+ // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs)
function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
diff --git a/.claude/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.claude/skills/impeccable/scripts/live-commit-manual-edits.mjs
index 44bc5ea4b..8bde46b50 100644
--- a/.claude/skills/impeccable/scripts/live-commit-manual-edits.mjs
+++ b/.claude/skills/impeccable/scripts/live-commit-manual-edits.mjs
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
-import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
-import { isGeneratedFile } from './is-generated.mjs';
+import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
diff --git a/.claude/skills/impeccable/scripts/live-complete.mjs b/.claude/skills/impeccable/scripts/live-complete.mjs
index 78155af85..1cc4e7543 100644
--- a/.claude/skills/impeccable/scripts/live-complete.mjs
+++ b/.claude/skills/impeccable/scripts/live-complete.mjs
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
diff --git a/.claude/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.claude/skills/impeccable/scripts/live-discard-manual-edits.mjs
index 435f22413..aa669fd20 100644
--- a/.claude/skills/impeccable/scripts/live-discard-manual-edits.mjs
+++ b/.claude/skills/impeccable/scripts/live-discard-manual-edits.mjs
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
-import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
+import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
diff --git a/.claude/skills/impeccable/scripts/live-inject.mjs b/.claude/skills/impeccable/scripts/live-inject.mjs
index 3a1f36e46..c006ad09e 100644
--- a/.claude/skills/impeccable/scripts/live-inject.mjs
+++ b/.claude/skills/impeccable/scripts/live-inject.mjs
@@ -16,12 +16,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { resolveLiveConfigPath } from './impeccable-paths.mjs';
+import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
-} from './live-sveltekit-adapter.mjs';
+} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
diff --git a/.claude/skills/impeccable/scripts/live-insert.mjs b/.claude/skills/impeccable/scripts/live-insert.mjs
index 0658e9914..0ed3cafea 100644
--- a/.claude/skills/impeccable/scripts/live-insert.mjs
+++ b/.claude/skills/impeccable/scripts/live-insert.mjs
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -25,7 +25,7 @@ import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
diff --git a/.claude/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.claude/skills/impeccable/scripts/live-manual-edit-evidence.mjs
index 860278b73..dd10e96dc 100644
--- a/.claude/skills/impeccable/scripts/live-manual-edit-evidence.mjs
+++ b/.claude/skills/impeccable/scripts/live-manual-edit-evidence.mjs
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
diff --git a/.claude/skills/impeccable/scripts/live-poll.mjs b/.claude/skills/impeccable/scripts/live-poll.mjs
index 1e1259fbc..b031055fe 100644
--- a/.claude/skills/impeccable/scripts/live-poll.mjs
+++ b/.claude/skills/impeccable/scripts/live-poll.mjs
@@ -12,8 +12,8 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
diff --git a/.claude/skills/impeccable/scripts/live-resume.mjs b/.claude/skills/impeccable/scripts/live-resume.mjs
index e54831f12..74284d48a 100644
--- a/.claude/skills/impeccable/scripts/live-resume.mjs
+++ b/.claude/skills/impeccable/scripts/live-resume.mjs
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs
index d9e06f449..187af0ac1 100644
--- a/.claude/skills/impeccable/scripts/live-server.mjs
+++ b/.claude/skills/impeccable/scripts/live-server.mjs
@@ -20,11 +20,11 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
-import { parseDesignMd } from './design-parser.mjs';
+import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { validateEvent } from './live-event-validation.mjs';
-import { LIVE_COMMANDS } from './live-vocabulary.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { validateEvent } from './live/event-validation.mjs';
+import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -33,20 +33,20 @@ import {
removeLiveServerInfo,
resolveDesignSidecarPath,
writeLiveServerInfo,
-} from './impeccable-paths.mjs';
+} from './lib/impeccable-paths.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
-} from './live-manual-edits-buffer.mjs';
+} from './live/manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
diff --git a/.claude/skills/impeccable/scripts/live-status.mjs b/.claude/skills/impeccable/scripts/live-status.mjs
index a7009cc29..f7e464999 100644
--- a/.claude/skills/impeccable/scripts/live-status.mjs
+++ b/.claude/skills/impeccable/scripts/live-status.mjs
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
diff --git a/.claude/skills/impeccable/scripts/live-wrap.mjs b/.claude/skills/impeccable/scripts/live-wrap.mjs
index a83cbcc56..c3f00f3a4 100644
--- a/.claude/skills/impeccable/scripts/live-wrap.mjs
+++ b/.claude/skills/impeccable/scripts/live-wrap.mjs
@@ -13,13 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.claude/skills/impeccable/scripts/live.mjs b/.claude/skills/impeccable/scripts/live.mjs
index 8acd300ed..0992da1bd 100644
--- a/.claude/skills/impeccable/scripts/live.mjs
+++ b/.claude/skills/impeccable/scripts/live.mjs
@@ -23,7 +23,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
diff --git a/.claude/skills/impeccable/scripts/live-completion.mjs b/.claude/skills/impeccable/scripts/live/completion.mjs
similarity index 100%
rename from .claude/skills/impeccable/scripts/live-completion.mjs
rename to .claude/skills/impeccable/scripts/live/completion.mjs
diff --git a/.cursor/skills/impeccable/scripts/live-event-validation.mjs b/.claude/skills/impeccable/scripts/live/event-validation.mjs
similarity index 98%
rename from .cursor/skills/impeccable/scripts/live-event-validation.mjs
rename to .claude/skills/impeccable/scripts/live/event-validation.mjs
index 15ae3265d..0897b1317 100644
--- a/.cursor/skills/impeccable/scripts/live-event-validation.mjs
+++ b/.claude/skills/impeccable/scripts/live/event-validation.mjs
@@ -3,12 +3,12 @@
* Extracted for unit testing (insert mode rules).
*/
-import { canCreateInsert } from './live-insert-ui.mjs';
+import { canCreateInsert } from './insert-ui.mjs';
// The accepted visual action values come from the canonical vocabulary so the
// validator, the picker UI, and the marketing demo never drift. Imported (not
// just re-exported) so it is also in scope for the validators below.
-import { VISUAL_ACTIONS } from './live-vocabulary.mjs';
+import { VISUAL_ACTIONS } from './vocabulary.mjs';
export { VISUAL_ACTIONS };
const ID_PATTERN = /^[0-9a-f]{8}$/;
diff --git a/.claude/skills/impeccable/scripts/live-insert-ui.mjs b/.claude/skills/impeccable/scripts/live/insert-ui.mjs
similarity index 100%
rename from .claude/skills/impeccable/scripts/live-insert-ui.mjs
rename to .claude/skills/impeccable/scripts/live/insert-ui.mjs
diff --git a/.agents/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.claude/skills/impeccable/scripts/live/manual-edits-buffer.mjs
similarity index 98%
rename from .agents/skills/impeccable/scripts/live-manual-edits-buffer.mjs
rename to .claude/skills/impeccable/scripts/live/manual-edits-buffer.mjs
index 9e3dcf455..d96ebbe34 100644
--- a/.agents/skills/impeccable/scripts/live-manual-edits-buffer.mjs
+++ b/.claude/skills/impeccable/scripts/live/manual-edits-buffer.mjs
@@ -12,7 +12,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { getLiveDir } from './impeccable-paths.mjs';
+import { getLiveDir } from '../lib/impeccable-paths.mjs';
const BUFFER_VERSION = 1;
const BUFFER_FILENAME = 'pending-manual-edits.json';
diff --git a/.gemini/skills/impeccable/scripts/live-session-store.mjs b/.claude/skills/impeccable/scripts/live/session-store.mjs
similarity index 99%
rename from .gemini/skills/impeccable/scripts/live-session-store.mjs
rename to .claude/skills/impeccable/scripts/live/session-store.mjs
index 5ec4d34d4..affba67c9 100644
--- a/.gemini/skills/impeccable/scripts/live-session-store.mjs
+++ b/.claude/skills/impeccable/scripts/live/session-store.mjs
@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
-import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
+import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
diff --git a/.claude/skills/impeccable/scripts/live-svelte-component.mjs b/.claude/skills/impeccable/scripts/live/svelte-component.mjs
similarity index 100%
rename from .claude/skills/impeccable/scripts/live-svelte-component.mjs
rename to .claude/skills/impeccable/scripts/live/svelte-component.mjs
diff --git a/.claude/skills/impeccable/scripts/live-sveltekit-adapter.mjs b/.claude/skills/impeccable/scripts/live/sveltekit-adapter.mjs
similarity index 100%
rename from .claude/skills/impeccable/scripts/live-sveltekit-adapter.mjs
rename to .claude/skills/impeccable/scripts/live/sveltekit-adapter.mjs
diff --git a/.claude/skills/impeccable/scripts/live-ui-core.mjs b/.claude/skills/impeccable/scripts/live/ui-core.mjs
similarity index 100%
rename from .claude/skills/impeccable/scripts/live-ui-core.mjs
rename to .claude/skills/impeccable/scripts/live/ui-core.mjs
diff --git a/.gemini/skills/impeccable/scripts/live-vocabulary.mjs b/.claude/skills/impeccable/scripts/live/vocabulary.mjs
similarity index 98%
rename from .gemini/skills/impeccable/scripts/live-vocabulary.mjs
rename to .claude/skills/impeccable/scripts/live/vocabulary.mjs
index 1123d41c4..5c7b0b713 100644
--- a/.gemini/skills/impeccable/scripts/live-vocabulary.mjs
+++ b/.claude/skills/impeccable/scripts/live/vocabulary.mjs
@@ -4,7 +4,7 @@
* 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/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
diff --git a/.cursor/skills/impeccable/scripts/context-signals.mjs b/.cursor/skills/impeccable/scripts/context-signals.mjs
index c03843812..f631d7561 100644
--- a/.cursor/skills/impeccable/scripts/context-signals.mjs
+++ b/.cursor/skills/impeccable/scripts/context-signals.mjs
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
diff --git a/.cursor/skills/impeccable/scripts/critique-storage.mjs b/.cursor/skills/impeccable/scripts/critique-storage.mjs
index eba12383e..645628c8c 100644
--- a/.cursor/skills/impeccable/scripts/critique-storage.mjs
+++ b/.cursor/skills/impeccable/scripts/critique-storage.mjs
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
diff --git a/.cursor/skills/impeccable/scripts/design-parser.mjs b/.cursor/skills/impeccable/scripts/lib/design-parser.mjs
similarity index 100%
rename from .cursor/skills/impeccable/scripts/design-parser.mjs
rename to .cursor/skills/impeccable/scripts/lib/design-parser.mjs
diff --git a/.cursor/skills/impeccable/scripts/impeccable-paths.mjs b/.cursor/skills/impeccable/scripts/lib/impeccable-paths.mjs
similarity index 100%
rename from .cursor/skills/impeccable/scripts/impeccable-paths.mjs
rename to .cursor/skills/impeccable/scripts/lib/impeccable-paths.mjs
diff --git a/.cursor/skills/impeccable/scripts/is-generated.mjs b/.cursor/skills/impeccable/scripts/lib/is-generated.mjs
similarity index 100%
rename from .cursor/skills/impeccable/scripts/is-generated.mjs
rename to .cursor/skills/impeccable/scripts/lib/is-generated.mjs
diff --git a/.cursor/skills/impeccable/scripts/live-accept.mjs b/.cursor/skills/impeccable/scripts/live-accept.mjs
index 5e8687187..278dfbb2e 100644
--- a/.cursor/skills/impeccable/scripts/live-accept.mjs
+++ b/.cursor/skills/impeccable/scripts/live-accept.mjs
@@ -15,14 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.cursor/skills/impeccable/scripts/live-browser.js b/.cursor/skills/impeccable/scripts/live-browser.js
index 0a533945f..9c4b6c21e 100644
--- a/.cursor/skills/impeccable/scripts/live-browser.js
+++ b/.cursor/skills/impeccable/scripts/live-browser.js
@@ -82,7 +82,7 @@
]);
// Command vocabulary (values + labels + icons) comes from the canonical source,
- // skill/scripts/live-vocabulary.mjs, which live-server.mjs serializes into
+ // skill/scripts/live/vocabulary.mjs, which live-server.mjs serializes into
// window.__IMPECCABLE_VOCAB__ when it serves /live.js (same injection path as
// the token/port above, so it is always present here). The icons stack above
// each chip label and recolor to C.brand when selected (strokes use
@@ -1216,7 +1216,7 @@
: (focused ? BP.accentSoft : BP.hairline);
}
- // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs)
+ // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs)
function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
diff --git a/.cursor/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.cursor/skills/impeccable/scripts/live-commit-manual-edits.mjs
index 44bc5ea4b..8bde46b50 100644
--- a/.cursor/skills/impeccable/scripts/live-commit-manual-edits.mjs
+++ b/.cursor/skills/impeccable/scripts/live-commit-manual-edits.mjs
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
-import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
-import { isGeneratedFile } from './is-generated.mjs';
+import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
diff --git a/.cursor/skills/impeccable/scripts/live-complete.mjs b/.cursor/skills/impeccable/scripts/live-complete.mjs
index 78155af85..1cc4e7543 100644
--- a/.cursor/skills/impeccable/scripts/live-complete.mjs
+++ b/.cursor/skills/impeccable/scripts/live-complete.mjs
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
diff --git a/.cursor/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.cursor/skills/impeccable/scripts/live-discard-manual-edits.mjs
index 435f22413..aa669fd20 100644
--- a/.cursor/skills/impeccable/scripts/live-discard-manual-edits.mjs
+++ b/.cursor/skills/impeccable/scripts/live-discard-manual-edits.mjs
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
-import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
+import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
diff --git a/.cursor/skills/impeccable/scripts/live-inject.mjs b/.cursor/skills/impeccable/scripts/live-inject.mjs
index 3a1f36e46..c006ad09e 100644
--- a/.cursor/skills/impeccable/scripts/live-inject.mjs
+++ b/.cursor/skills/impeccable/scripts/live-inject.mjs
@@ -16,12 +16,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { resolveLiveConfigPath } from './impeccable-paths.mjs';
+import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
-} from './live-sveltekit-adapter.mjs';
+} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
diff --git a/.cursor/skills/impeccable/scripts/live-insert.mjs b/.cursor/skills/impeccable/scripts/live-insert.mjs
index 0658e9914..0ed3cafea 100644
--- a/.cursor/skills/impeccable/scripts/live-insert.mjs
+++ b/.cursor/skills/impeccable/scripts/live-insert.mjs
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -25,7 +25,7 @@ import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
diff --git a/.cursor/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.cursor/skills/impeccable/scripts/live-manual-edit-evidence.mjs
index 860278b73..dd10e96dc 100644
--- a/.cursor/skills/impeccable/scripts/live-manual-edit-evidence.mjs
+++ b/.cursor/skills/impeccable/scripts/live-manual-edit-evidence.mjs
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
diff --git a/.cursor/skills/impeccable/scripts/live-poll.mjs b/.cursor/skills/impeccable/scripts/live-poll.mjs
index 1e1259fbc..b031055fe 100644
--- a/.cursor/skills/impeccable/scripts/live-poll.mjs
+++ b/.cursor/skills/impeccable/scripts/live-poll.mjs
@@ -12,8 +12,8 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
diff --git a/.cursor/skills/impeccable/scripts/live-resume.mjs b/.cursor/skills/impeccable/scripts/live-resume.mjs
index e54831f12..74284d48a 100644
--- a/.cursor/skills/impeccable/scripts/live-resume.mjs
+++ b/.cursor/skills/impeccable/scripts/live-resume.mjs
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs
index d9e06f449..187af0ac1 100644
--- a/.cursor/skills/impeccable/scripts/live-server.mjs
+++ b/.cursor/skills/impeccable/scripts/live-server.mjs
@@ -20,11 +20,11 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
-import { parseDesignMd } from './design-parser.mjs';
+import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { validateEvent } from './live-event-validation.mjs';
-import { LIVE_COMMANDS } from './live-vocabulary.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { validateEvent } from './live/event-validation.mjs';
+import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -33,20 +33,20 @@ import {
removeLiveServerInfo,
resolveDesignSidecarPath,
writeLiveServerInfo,
-} from './impeccable-paths.mjs';
+} from './lib/impeccable-paths.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
-} from './live-manual-edits-buffer.mjs';
+} from './live/manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
diff --git a/.cursor/skills/impeccable/scripts/live-status.mjs b/.cursor/skills/impeccable/scripts/live-status.mjs
index a7009cc29..f7e464999 100644
--- a/.cursor/skills/impeccable/scripts/live-status.mjs
+++ b/.cursor/skills/impeccable/scripts/live-status.mjs
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
diff --git a/.cursor/skills/impeccable/scripts/live-wrap.mjs b/.cursor/skills/impeccable/scripts/live-wrap.mjs
index a83cbcc56..c3f00f3a4 100644
--- a/.cursor/skills/impeccable/scripts/live-wrap.mjs
+++ b/.cursor/skills/impeccable/scripts/live-wrap.mjs
@@ -13,13 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.cursor/skills/impeccable/scripts/live.mjs b/.cursor/skills/impeccable/scripts/live.mjs
index 8acd300ed..0992da1bd 100644
--- a/.cursor/skills/impeccable/scripts/live.mjs
+++ b/.cursor/skills/impeccable/scripts/live.mjs
@@ -23,7 +23,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
diff --git a/.cursor/skills/impeccable/scripts/live-completion.mjs b/.cursor/skills/impeccable/scripts/live/completion.mjs
similarity index 100%
rename from .cursor/skills/impeccable/scripts/live-completion.mjs
rename to .cursor/skills/impeccable/scripts/live/completion.mjs
diff --git a/.gemini/skills/impeccable/scripts/live-event-validation.mjs b/.cursor/skills/impeccable/scripts/live/event-validation.mjs
similarity index 98%
rename from .gemini/skills/impeccable/scripts/live-event-validation.mjs
rename to .cursor/skills/impeccable/scripts/live/event-validation.mjs
index 15ae3265d..0897b1317 100644
--- a/.gemini/skills/impeccable/scripts/live-event-validation.mjs
+++ b/.cursor/skills/impeccable/scripts/live/event-validation.mjs
@@ -3,12 +3,12 @@
* Extracted for unit testing (insert mode rules).
*/
-import { canCreateInsert } from './live-insert-ui.mjs';
+import { canCreateInsert } from './insert-ui.mjs';
// The accepted visual action values come from the canonical vocabulary so the
// validator, the picker UI, and the marketing demo never drift. Imported (not
// just re-exported) so it is also in scope for the validators below.
-import { VISUAL_ACTIONS } from './live-vocabulary.mjs';
+import { VISUAL_ACTIONS } from './vocabulary.mjs';
export { VISUAL_ACTIONS };
const ID_PATTERN = /^[0-9a-f]{8}$/;
diff --git a/.cursor/skills/impeccable/scripts/live-insert-ui.mjs b/.cursor/skills/impeccable/scripts/live/insert-ui.mjs
similarity index 100%
rename from .cursor/skills/impeccable/scripts/live-insert-ui.mjs
rename to .cursor/skills/impeccable/scripts/live/insert-ui.mjs
diff --git a/.gemini/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.cursor/skills/impeccable/scripts/live/manual-edits-buffer.mjs
similarity index 98%
rename from .gemini/skills/impeccable/scripts/live-manual-edits-buffer.mjs
rename to .cursor/skills/impeccable/scripts/live/manual-edits-buffer.mjs
index 9e3dcf455..d96ebbe34 100644
--- a/.gemini/skills/impeccable/scripts/live-manual-edits-buffer.mjs
+++ b/.cursor/skills/impeccable/scripts/live/manual-edits-buffer.mjs
@@ -12,7 +12,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { getLiveDir } from './impeccable-paths.mjs';
+import { getLiveDir } from '../lib/impeccable-paths.mjs';
const BUFFER_VERSION = 1;
const BUFFER_FILENAME = 'pending-manual-edits.json';
diff --git a/.agents/skills/impeccable/scripts/live-session-store.mjs b/.cursor/skills/impeccable/scripts/live/session-store.mjs
similarity index 99%
rename from .agents/skills/impeccable/scripts/live-session-store.mjs
rename to .cursor/skills/impeccable/scripts/live/session-store.mjs
index 5ec4d34d4..affba67c9 100644
--- a/.agents/skills/impeccable/scripts/live-session-store.mjs
+++ b/.cursor/skills/impeccable/scripts/live/session-store.mjs
@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
-import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
+import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
diff --git a/.cursor/skills/impeccable/scripts/live-svelte-component.mjs b/.cursor/skills/impeccable/scripts/live/svelte-component.mjs
similarity index 100%
rename from .cursor/skills/impeccable/scripts/live-svelte-component.mjs
rename to .cursor/skills/impeccable/scripts/live/svelte-component.mjs
diff --git a/.cursor/skills/impeccable/scripts/live-sveltekit-adapter.mjs b/.cursor/skills/impeccable/scripts/live/sveltekit-adapter.mjs
similarity index 100%
rename from .cursor/skills/impeccable/scripts/live-sveltekit-adapter.mjs
rename to .cursor/skills/impeccable/scripts/live/sveltekit-adapter.mjs
diff --git a/.cursor/skills/impeccable/scripts/live-ui-core.mjs b/.cursor/skills/impeccable/scripts/live/ui-core.mjs
similarity index 100%
rename from .cursor/skills/impeccable/scripts/live-ui-core.mjs
rename to .cursor/skills/impeccable/scripts/live/ui-core.mjs
diff --git a/.claude/skills/impeccable/scripts/live-vocabulary.mjs b/.cursor/skills/impeccable/scripts/live/vocabulary.mjs
similarity index 98%
rename from .claude/skills/impeccable/scripts/live-vocabulary.mjs
rename to .cursor/skills/impeccable/scripts/live/vocabulary.mjs
index 1123d41c4..5c7b0b713 100644
--- a/.claude/skills/impeccable/scripts/live-vocabulary.mjs
+++ b/.cursor/skills/impeccable/scripts/live/vocabulary.mjs
@@ -4,7 +4,7 @@
* 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/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
diff --git a/.gemini/skills/impeccable/scripts/context-signals.mjs b/.gemini/skills/impeccable/scripts/context-signals.mjs
index c03843812..f631d7561 100644
--- a/.gemini/skills/impeccable/scripts/context-signals.mjs
+++ b/.gemini/skills/impeccable/scripts/context-signals.mjs
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
diff --git a/.gemini/skills/impeccable/scripts/critique-storage.mjs b/.gemini/skills/impeccable/scripts/critique-storage.mjs
index eba12383e..645628c8c 100644
--- a/.gemini/skills/impeccable/scripts/critique-storage.mjs
+++ b/.gemini/skills/impeccable/scripts/critique-storage.mjs
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
diff --git a/.gemini/skills/impeccable/scripts/design-parser.mjs b/.gemini/skills/impeccable/scripts/lib/design-parser.mjs
similarity index 100%
rename from .gemini/skills/impeccable/scripts/design-parser.mjs
rename to .gemini/skills/impeccable/scripts/lib/design-parser.mjs
diff --git a/.gemini/skills/impeccable/scripts/impeccable-paths.mjs b/.gemini/skills/impeccable/scripts/lib/impeccable-paths.mjs
similarity index 100%
rename from .gemini/skills/impeccable/scripts/impeccable-paths.mjs
rename to .gemini/skills/impeccable/scripts/lib/impeccable-paths.mjs
diff --git a/.gemini/skills/impeccable/scripts/is-generated.mjs b/.gemini/skills/impeccable/scripts/lib/is-generated.mjs
similarity index 100%
rename from .gemini/skills/impeccable/scripts/is-generated.mjs
rename to .gemini/skills/impeccable/scripts/lib/is-generated.mjs
diff --git a/.gemini/skills/impeccable/scripts/live-accept.mjs b/.gemini/skills/impeccable/scripts/live-accept.mjs
index 5e8687187..278dfbb2e 100644
--- a/.gemini/skills/impeccable/scripts/live-accept.mjs
+++ b/.gemini/skills/impeccable/scripts/live-accept.mjs
@@ -15,14 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.gemini/skills/impeccable/scripts/live-browser.js b/.gemini/skills/impeccable/scripts/live-browser.js
index 0a533945f..9c4b6c21e 100644
--- a/.gemini/skills/impeccable/scripts/live-browser.js
+++ b/.gemini/skills/impeccable/scripts/live-browser.js
@@ -82,7 +82,7 @@
]);
// Command vocabulary (values + labels + icons) comes from the canonical source,
- // skill/scripts/live-vocabulary.mjs, which live-server.mjs serializes into
+ // skill/scripts/live/vocabulary.mjs, which live-server.mjs serializes into
// window.__IMPECCABLE_VOCAB__ when it serves /live.js (same injection path as
// the token/port above, so it is always present here). The icons stack above
// each chip label and recolor to C.brand when selected (strokes use
@@ -1216,7 +1216,7 @@
: (focused ? BP.accentSoft : BP.hairline);
}
- // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs)
+ // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs)
function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
diff --git a/.gemini/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.gemini/skills/impeccable/scripts/live-commit-manual-edits.mjs
index 44bc5ea4b..8bde46b50 100644
--- a/.gemini/skills/impeccable/scripts/live-commit-manual-edits.mjs
+++ b/.gemini/skills/impeccable/scripts/live-commit-manual-edits.mjs
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
-import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
-import { isGeneratedFile } from './is-generated.mjs';
+import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
diff --git a/.gemini/skills/impeccable/scripts/live-complete.mjs b/.gemini/skills/impeccable/scripts/live-complete.mjs
index 78155af85..1cc4e7543 100644
--- a/.gemini/skills/impeccable/scripts/live-complete.mjs
+++ b/.gemini/skills/impeccable/scripts/live-complete.mjs
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
diff --git a/.gemini/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.gemini/skills/impeccable/scripts/live-discard-manual-edits.mjs
index 435f22413..aa669fd20 100644
--- a/.gemini/skills/impeccable/scripts/live-discard-manual-edits.mjs
+++ b/.gemini/skills/impeccable/scripts/live-discard-manual-edits.mjs
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
-import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
+import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
diff --git a/.gemini/skills/impeccable/scripts/live-inject.mjs b/.gemini/skills/impeccable/scripts/live-inject.mjs
index 3a1f36e46..c006ad09e 100644
--- a/.gemini/skills/impeccable/scripts/live-inject.mjs
+++ b/.gemini/skills/impeccable/scripts/live-inject.mjs
@@ -16,12 +16,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { resolveLiveConfigPath } from './impeccable-paths.mjs';
+import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
-} from './live-sveltekit-adapter.mjs';
+} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
diff --git a/.gemini/skills/impeccable/scripts/live-insert.mjs b/.gemini/skills/impeccable/scripts/live-insert.mjs
index 0658e9914..0ed3cafea 100644
--- a/.gemini/skills/impeccable/scripts/live-insert.mjs
+++ b/.gemini/skills/impeccable/scripts/live-insert.mjs
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -25,7 +25,7 @@ import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
diff --git a/.gemini/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.gemini/skills/impeccable/scripts/live-manual-edit-evidence.mjs
index 860278b73..dd10e96dc 100644
--- a/.gemini/skills/impeccable/scripts/live-manual-edit-evidence.mjs
+++ b/.gemini/skills/impeccable/scripts/live-manual-edit-evidence.mjs
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
diff --git a/.gemini/skills/impeccable/scripts/live-poll.mjs b/.gemini/skills/impeccable/scripts/live-poll.mjs
index 1e1259fbc..b031055fe 100644
--- a/.gemini/skills/impeccable/scripts/live-poll.mjs
+++ b/.gemini/skills/impeccable/scripts/live-poll.mjs
@@ -12,8 +12,8 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
diff --git a/.gemini/skills/impeccable/scripts/live-resume.mjs b/.gemini/skills/impeccable/scripts/live-resume.mjs
index e54831f12..74284d48a 100644
--- a/.gemini/skills/impeccable/scripts/live-resume.mjs
+++ b/.gemini/skills/impeccable/scripts/live-resume.mjs
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs
index d9e06f449..187af0ac1 100644
--- a/.gemini/skills/impeccable/scripts/live-server.mjs
+++ b/.gemini/skills/impeccable/scripts/live-server.mjs
@@ -20,11 +20,11 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
-import { parseDesignMd } from './design-parser.mjs';
+import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { validateEvent } from './live-event-validation.mjs';
-import { LIVE_COMMANDS } from './live-vocabulary.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { validateEvent } from './live/event-validation.mjs';
+import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -33,20 +33,20 @@ import {
removeLiveServerInfo,
resolveDesignSidecarPath,
writeLiveServerInfo,
-} from './impeccable-paths.mjs';
+} from './lib/impeccable-paths.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
-} from './live-manual-edits-buffer.mjs';
+} from './live/manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
diff --git a/.gemini/skills/impeccable/scripts/live-status.mjs b/.gemini/skills/impeccable/scripts/live-status.mjs
index a7009cc29..f7e464999 100644
--- a/.gemini/skills/impeccable/scripts/live-status.mjs
+++ b/.gemini/skills/impeccable/scripts/live-status.mjs
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
diff --git a/.gemini/skills/impeccable/scripts/live-wrap.mjs b/.gemini/skills/impeccable/scripts/live-wrap.mjs
index a83cbcc56..c3f00f3a4 100644
--- a/.gemini/skills/impeccable/scripts/live-wrap.mjs
+++ b/.gemini/skills/impeccable/scripts/live-wrap.mjs
@@ -13,13 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.gemini/skills/impeccable/scripts/live.mjs b/.gemini/skills/impeccable/scripts/live.mjs
index 8acd300ed..0992da1bd 100644
--- a/.gemini/skills/impeccable/scripts/live.mjs
+++ b/.gemini/skills/impeccable/scripts/live.mjs
@@ -23,7 +23,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
diff --git a/.gemini/skills/impeccable/scripts/live-completion.mjs b/.gemini/skills/impeccable/scripts/live/completion.mjs
similarity index 100%
rename from .gemini/skills/impeccable/scripts/live-completion.mjs
rename to .gemini/skills/impeccable/scripts/live/completion.mjs
diff --git a/.claude/skills/impeccable/scripts/live-event-validation.mjs b/.gemini/skills/impeccable/scripts/live/event-validation.mjs
similarity index 98%
rename from .claude/skills/impeccable/scripts/live-event-validation.mjs
rename to .gemini/skills/impeccable/scripts/live/event-validation.mjs
index 15ae3265d..0897b1317 100644
--- a/.claude/skills/impeccable/scripts/live-event-validation.mjs
+++ b/.gemini/skills/impeccable/scripts/live/event-validation.mjs
@@ -3,12 +3,12 @@
* Extracted for unit testing (insert mode rules).
*/
-import { canCreateInsert } from './live-insert-ui.mjs';
+import { canCreateInsert } from './insert-ui.mjs';
// The accepted visual action values come from the canonical vocabulary so the
// validator, the picker UI, and the marketing demo never drift. Imported (not
// just re-exported) so it is also in scope for the validators below.
-import { VISUAL_ACTIONS } from './live-vocabulary.mjs';
+import { VISUAL_ACTIONS } from './vocabulary.mjs';
export { VISUAL_ACTIONS };
const ID_PATTERN = /^[0-9a-f]{8}$/;
diff --git a/.gemini/skills/impeccable/scripts/live-insert-ui.mjs b/.gemini/skills/impeccable/scripts/live/insert-ui.mjs
similarity index 100%
rename from .gemini/skills/impeccable/scripts/live-insert-ui.mjs
rename to .gemini/skills/impeccable/scripts/live/insert-ui.mjs
diff --git a/.claude/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.gemini/skills/impeccable/scripts/live/manual-edits-buffer.mjs
similarity index 98%
rename from .claude/skills/impeccable/scripts/live-manual-edits-buffer.mjs
rename to .gemini/skills/impeccable/scripts/live/manual-edits-buffer.mjs
index 9e3dcf455..d96ebbe34 100644
--- a/.claude/skills/impeccable/scripts/live-manual-edits-buffer.mjs
+++ b/.gemini/skills/impeccable/scripts/live/manual-edits-buffer.mjs
@@ -12,7 +12,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { getLiveDir } from './impeccable-paths.mjs';
+import { getLiveDir } from '../lib/impeccable-paths.mjs';
const BUFFER_VERSION = 1;
const BUFFER_FILENAME = 'pending-manual-edits.json';
diff --git a/.claude/skills/impeccable/scripts/live-session-store.mjs b/.gemini/skills/impeccable/scripts/live/session-store.mjs
similarity index 99%
rename from .claude/skills/impeccable/scripts/live-session-store.mjs
rename to .gemini/skills/impeccable/scripts/live/session-store.mjs
index 5ec4d34d4..affba67c9 100644
--- a/.claude/skills/impeccable/scripts/live-session-store.mjs
+++ b/.gemini/skills/impeccable/scripts/live/session-store.mjs
@@ -1,6 +1,6 @@
import fs from 'node:fs';
import path from 'node:path';
-import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
+import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
const COMPLETED_PHASES = new Set(['completed', 'discarded']);
diff --git a/.gemini/skills/impeccable/scripts/live-svelte-component.mjs b/.gemini/skills/impeccable/scripts/live/svelte-component.mjs
similarity index 100%
rename from .gemini/skills/impeccable/scripts/live-svelte-component.mjs
rename to .gemini/skills/impeccable/scripts/live/svelte-component.mjs
diff --git a/.gemini/skills/impeccable/scripts/live-sveltekit-adapter.mjs b/.gemini/skills/impeccable/scripts/live/sveltekit-adapter.mjs
similarity index 100%
rename from .gemini/skills/impeccable/scripts/live-sveltekit-adapter.mjs
rename to .gemini/skills/impeccable/scripts/live/sveltekit-adapter.mjs
diff --git a/.gemini/skills/impeccable/scripts/live-ui-core.mjs b/.gemini/skills/impeccable/scripts/live/ui-core.mjs
similarity index 100%
rename from .gemini/skills/impeccable/scripts/live-ui-core.mjs
rename to .gemini/skills/impeccable/scripts/live/ui-core.mjs
diff --git a/.agents/skills/impeccable/scripts/live-vocabulary.mjs b/.gemini/skills/impeccable/scripts/live/vocabulary.mjs
similarity index 98%
rename from .agents/skills/impeccable/scripts/live-vocabulary.mjs
rename to .gemini/skills/impeccable/scripts/live/vocabulary.mjs
index 1123d41c4..5c7b0b713 100644
--- a/.agents/skills/impeccable/scripts/live-vocabulary.mjs
+++ b/.gemini/skills/impeccable/scripts/live/vocabulary.mjs
@@ -4,7 +4,7 @@
* 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/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
diff --git a/.github/skills/impeccable/scripts/context-signals.mjs b/.github/skills/impeccable/scripts/context-signals.mjs
index c03843812..f631d7561 100644
--- a/.github/skills/impeccable/scripts/context-signals.mjs
+++ b/.github/skills/impeccable/scripts/context-signals.mjs
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
diff --git a/.github/skills/impeccable/scripts/critique-storage.mjs b/.github/skills/impeccable/scripts/critique-storage.mjs
index eba12383e..645628c8c 100644
--- a/.github/skills/impeccable/scripts/critique-storage.mjs
+++ b/.github/skills/impeccable/scripts/critique-storage.mjs
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
diff --git a/.github/skills/impeccable/scripts/design-parser.mjs b/.github/skills/impeccable/scripts/lib/design-parser.mjs
similarity index 100%
rename from .github/skills/impeccable/scripts/design-parser.mjs
rename to .github/skills/impeccable/scripts/lib/design-parser.mjs
diff --git a/.github/skills/impeccable/scripts/impeccable-paths.mjs b/.github/skills/impeccable/scripts/lib/impeccable-paths.mjs
similarity index 100%
rename from .github/skills/impeccable/scripts/impeccable-paths.mjs
rename to .github/skills/impeccable/scripts/lib/impeccable-paths.mjs
diff --git a/.github/skills/impeccable/scripts/is-generated.mjs b/.github/skills/impeccable/scripts/lib/is-generated.mjs
similarity index 100%
rename from .github/skills/impeccable/scripts/is-generated.mjs
rename to .github/skills/impeccable/scripts/lib/is-generated.mjs
diff --git a/.github/skills/impeccable/scripts/live-accept.mjs b/.github/skills/impeccable/scripts/live-accept.mjs
index 5e8687187..278dfbb2e 100644
--- a/.github/skills/impeccable/scripts/live-accept.mjs
+++ b/.github/skills/impeccable/scripts/live-accept.mjs
@@ -15,14 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.github/skills/impeccable/scripts/live-browser.js b/.github/skills/impeccable/scripts/live-browser.js
index 0a533945f..9c4b6c21e 100644
--- a/.github/skills/impeccable/scripts/live-browser.js
+++ b/.github/skills/impeccable/scripts/live-browser.js
@@ -82,7 +82,7 @@
]);
// Command vocabulary (values + labels + icons) comes from the canonical source,
- // skill/scripts/live-vocabulary.mjs, which live-server.mjs serializes into
+ // skill/scripts/live/vocabulary.mjs, which live-server.mjs serializes into
// window.__IMPECCABLE_VOCAB__ when it serves /live.js (same injection path as
// the token/port above, so it is always present here). The icons stack above
// each chip label and recolor to C.brand when selected (strokes use
@@ -1216,7 +1216,7 @@
: (focused ? BP.accentSoft : BP.hairline);
}
- // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs)
+ // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs)
function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
diff --git a/.github/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.github/skills/impeccable/scripts/live-commit-manual-edits.mjs
index 44bc5ea4b..8bde46b50 100644
--- a/.github/skills/impeccable/scripts/live-commit-manual-edits.mjs
+++ b/.github/skills/impeccable/scripts/live-commit-manual-edits.mjs
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
-import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
-import { isGeneratedFile } from './is-generated.mjs';
+import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
diff --git a/.github/skills/impeccable/scripts/live-complete.mjs b/.github/skills/impeccable/scripts/live-complete.mjs
index 78155af85..1cc4e7543 100644
--- a/.github/skills/impeccable/scripts/live-complete.mjs
+++ b/.github/skills/impeccable/scripts/live-complete.mjs
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
diff --git a/.github/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.github/skills/impeccable/scripts/live-discard-manual-edits.mjs
index 435f22413..aa669fd20 100644
--- a/.github/skills/impeccable/scripts/live-discard-manual-edits.mjs
+++ b/.github/skills/impeccable/scripts/live-discard-manual-edits.mjs
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
-import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
+import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
diff --git a/.github/skills/impeccable/scripts/live-event-validation.mjs b/.github/skills/impeccable/scripts/live-event-validation.mjs
deleted file mode 100644
index 15ae3265d..000000000
--- a/.github/skills/impeccable/scripts/live-event-validation.mjs
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * Shared event validation for the live helper server.
- * Extracted for unit testing (insert mode rules).
- */
-
-import { canCreateInsert } from './live-insert-ui.mjs';
-
-// The accepted visual action values come from the canonical vocabulary so the
-// validator, the picker UI, and the marketing demo never drift. Imported (not
-// just re-exported) so it is also in scope for the validators below.
-import { VISUAL_ACTIONS } from './live-vocabulary.mjs';
-export { VISUAL_ACTIONS };
-
-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 = ['<', '{', '}', '`'];
-
-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;
-}
-
-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 '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;
- default:
- return 'Unknown event type: ' + msg.type;
- }
-}
diff --git a/.github/skills/impeccable/scripts/live-inject.mjs b/.github/skills/impeccable/scripts/live-inject.mjs
index 3a1f36e46..c006ad09e 100644
--- a/.github/skills/impeccable/scripts/live-inject.mjs
+++ b/.github/skills/impeccable/scripts/live-inject.mjs
@@ -16,12 +16,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { resolveLiveConfigPath } from './impeccable-paths.mjs';
+import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
-} from './live-sveltekit-adapter.mjs';
+} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
diff --git a/.github/skills/impeccable/scripts/live-insert.mjs b/.github/skills/impeccable/scripts/live-insert.mjs
index 0658e9914..0ed3cafea 100644
--- a/.github/skills/impeccable/scripts/live-insert.mjs
+++ b/.github/skills/impeccable/scripts/live-insert.mjs
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -25,7 +25,7 @@ import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
diff --git a/.github/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.github/skills/impeccable/scripts/live-manual-edit-evidence.mjs
index 860278b73..dd10e96dc 100644
--- a/.github/skills/impeccable/scripts/live-manual-edit-evidence.mjs
+++ b/.github/skills/impeccable/scripts/live-manual-edit-evidence.mjs
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
diff --git a/.github/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.github/skills/impeccable/scripts/live-manual-edits-buffer.mjs
deleted file mode 100644
index 9e3dcf455..000000000
--- a/.github/skills/impeccable/scripts/live-manual-edits-buffer.mjs
+++ /dev/null
@@ -1,152 +0,0 @@
-/**
- * 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 './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;
-}
diff --git a/.github/skills/impeccable/scripts/live-poll.mjs b/.github/skills/impeccable/scripts/live-poll.mjs
index 1e1259fbc..b031055fe 100644
--- a/.github/skills/impeccable/scripts/live-poll.mjs
+++ b/.github/skills/impeccable/scripts/live-poll.mjs
@@ -12,8 +12,8 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
diff --git a/.github/skills/impeccable/scripts/live-resume.mjs b/.github/skills/impeccable/scripts/live-resume.mjs
index e54831f12..74284d48a 100644
--- a/.github/skills/impeccable/scripts/live-resume.mjs
+++ b/.github/skills/impeccable/scripts/live-resume.mjs
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs
index d9e06f449..187af0ac1 100644
--- a/.github/skills/impeccable/scripts/live-server.mjs
+++ b/.github/skills/impeccable/scripts/live-server.mjs
@@ -20,11 +20,11 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
-import { parseDesignMd } from './design-parser.mjs';
+import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { validateEvent } from './live-event-validation.mjs';
-import { LIVE_COMMANDS } from './live-vocabulary.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { validateEvent } from './live/event-validation.mjs';
+import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -33,20 +33,20 @@ import {
removeLiveServerInfo,
resolveDesignSidecarPath,
writeLiveServerInfo,
-} from './impeccable-paths.mjs';
+} from './lib/impeccable-paths.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
-} from './live-manual-edits-buffer.mjs';
+} from './live/manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
diff --git a/.github/skills/impeccable/scripts/live-session-store.mjs b/.github/skills/impeccable/scripts/live-session-store.mjs
deleted file mode 100644
index 5ec4d34d4..000000000
--- a/.github/skills/impeccable/scripts/live-session-store.mjs
+++ /dev/null
@@ -1,289 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
-
-const COMPLETED_PHASES = new Set(['completed', 'discarded']);
-
-export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
- const rootDir = getLiveSessionsDir(cwd);
- const legacyRootDir = getLegacyLiveSessionsDir(cwd);
- fs.mkdirSync(rootDir, { recursive: true });
- const snapshotCache = new Map();
-
- function loadCachedOrRebuild(id) {
- const cached = snapshotCache.get(id);
- if (cached) return cached;
- const journalPath = getReadableJournalPath(id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- return rebuilt;
- }
-
- 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;
- }
-
- return {
- rootDir,
- legacyRootDir,
- appendEvent(event) {
- const normalized = normalizeEvent(event, sessionId);
- const journalPath = getJournalPath(rootDir, normalized.id);
- const snapshotPath = getSnapshotPath(rootDir, normalized.id);
- const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
- if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
- fs.copyFileSync(legacyJournalPath, journalPath);
- }
- const prior = loadCachedOrRebuild(normalized.id);
- const seq = prior.nextSeq;
- const entry = {
- seq,
- 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, prior.diagnostics);
- snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
- writeSnapshot(snapshotPath, next);
- return next;
- },
- getSnapshot(id = sessionId, opts = {}) {
- if (!id) throw new Error('session id required');
- const journalPath = getReadableJournalPath(id);
- const snapshotPath = getSnapshotPath(rootDir, id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- writeSnapshot(snapshotPath, rebuilt.snapshot);
- if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
- return rebuilt.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));
- }
- }
- return [...ids]
- .sort()
- .map((id) => this.getSnapshot(id))
- .filter(Boolean);
- },
- };
-}
-
-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 safeSessionId(id) {
- if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
- return id;
-}
-
-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,
- activeOwner: null,
- sourceMarkers: {},
- fallbackMode: null,
- annotationArtifacts: [],
- diagnostics: [],
- updatedAt: 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, inheritedDiagnostics = []) {
- const event = entry.event || entry;
- const next = {
- ...snapshot,
- paramValues: { ...(snapshot.paramValues || {}) },
- sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
- annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
- diagnostics: [...(snapshot.diagnostics || [])],
- updatedAt: entry.ts || new Date().toISOString(),
- };
-
- if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
- next.diagnostics = [...inheritedDiagnostics];
- }
-
- 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);
- if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
- break;
- case 'variants_ready':
- case 'agent_done':
- next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
- 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.',
- });
- }
- break;
- case 'checkpoint':
- if (COMPLETED_PHASES.has(next.phase)) {
- next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
- break;
- }
- if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
- next.phase = event.phase ?? next.phase;
- next.checkpointRevision = event.revision ?? next.checkpointRevision;
- next.activeOwner = event.owner ?? next.activeOwner;
- next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
- next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
- next.sourceFile = event.sourceFile ?? next.sourceFile;
- next.previewFile = event.previewFile ?? next.previewFile;
- next.previewMode = event.previewMode ?? next.previewMode;
- if (event.paramValues) next.paramValues = { ...event.paramValues };
- } else {
- next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
- }
- break;
- case 'accept':
- case 'accept_intent':
- next.phase = 'accept_requested';
- 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 '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.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':
- 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) {
- fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
-}
diff --git a/.github/skills/impeccable/scripts/live-status.mjs b/.github/skills/impeccable/scripts/live-status.mjs
index a7009cc29..f7e464999 100644
--- a/.github/skills/impeccable/scripts/live-status.mjs
+++ b/.github/skills/impeccable/scripts/live-status.mjs
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
diff --git a/.github/skills/impeccable/scripts/live-vocabulary.mjs b/.github/skills/impeccable/scripts/live-vocabulary.mjs
deleted file mode 100644
index 1123d41c4..000000000
--- a/.github/skills/impeccable/scripts/live-vocabulary.mjs
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * 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: `` },
- { value: 'bolder', label: 'Bolder', icon: `` },
- { value: 'quieter', label: 'Quieter', icon: `` },
- { value: 'distill', label: 'Distill', icon: `` },
- { value: 'polish', label: 'Polish', icon: `` },
- { value: 'typeset', label: 'Typeset', icon: `` },
- { value: 'colorize', label: 'Colorize', icon: `` },
- { value: 'layout', label: 'Layout', icon: `` },
- { value: 'adapt', label: 'Adapt', icon: `` },
- { value: 'animate', label: 'Animate', icon: `` },
- { value: 'delight', label: 'Delight', icon: `` },
- { value: 'overdrive', label: 'Overdrive', icon: `` },
-];
-
-// Action values accepted by the live event protocol, in palette order.
-export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.github/skills/impeccable/scripts/live-wrap.mjs b/.github/skills/impeccable/scripts/live-wrap.mjs
index a83cbcc56..c3f00f3a4 100644
--- a/.github/skills/impeccable/scripts/live-wrap.mjs
+++ b/.github/skills/impeccable/scripts/live-wrap.mjs
@@ -13,13 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.github/skills/impeccable/scripts/live.mjs b/.github/skills/impeccable/scripts/live.mjs
index 8acd300ed..0992da1bd 100644
--- a/.github/skills/impeccable/scripts/live.mjs
+++ b/.github/skills/impeccable/scripts/live.mjs
@@ -23,7 +23,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
diff --git a/.github/skills/impeccable/scripts/live-completion.mjs b/.github/skills/impeccable/scripts/live/completion.mjs
similarity index 100%
rename from .github/skills/impeccable/scripts/live-completion.mjs
rename to .github/skills/impeccable/scripts/live/completion.mjs
diff --git a/.github/skills/impeccable/scripts/live/event-validation.mjs b/.github/skills/impeccable/scripts/live/event-validation.mjs
new file mode 100644
index 000000000..0897b1317
--- /dev/null
+++ b/.github/skills/impeccable/scripts/live/event-validation.mjs
@@ -0,0 +1,137 @@
+/**
+ * Shared event validation for the live helper server.
+ * Extracted for unit testing (insert mode rules).
+ */
+
+import { canCreateInsert } from './insert-ui.mjs';
+
+// The accepted visual action values come from the canonical vocabulary so the
+// validator, the picker UI, and the marketing demo never drift. Imported (not
+// just re-exported) so it is also in scope for the validators below.
+import { VISUAL_ACTIONS } from './vocabulary.mjs';
+export { VISUAL_ACTIONS };
+
+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 = ['<', '{', '}', '`'];
+
+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;
+}
+
+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 '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;
+ default:
+ return 'Unknown event type: ' + msg.type;
+ }
+}
diff --git a/.github/skills/impeccable/scripts/live-insert-ui.mjs b/.github/skills/impeccable/scripts/live/insert-ui.mjs
similarity index 100%
rename from .github/skills/impeccable/scripts/live-insert-ui.mjs
rename to .github/skills/impeccable/scripts/live/insert-ui.mjs
diff --git a/.github/skills/impeccable/scripts/live/manual-edits-buffer.mjs b/.github/skills/impeccable/scripts/live/manual-edits-buffer.mjs
new file mode 100644
index 000000000..d96ebbe34
--- /dev/null
+++ b/.github/skills/impeccable/scripts/live/manual-edits-buffer.mjs
@@ -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;
+}
diff --git a/.github/skills/impeccable/scripts/live/session-store.mjs b/.github/skills/impeccable/scripts/live/session-store.mjs
new file mode 100644
index 000000000..affba67c9
--- /dev/null
+++ b/.github/skills/impeccable/scripts/live/session-store.mjs
@@ -0,0 +1,289 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
+
+const COMPLETED_PHASES = new Set(['completed', 'discarded']);
+
+export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
+ const rootDir = getLiveSessionsDir(cwd);
+ const legacyRootDir = getLegacyLiveSessionsDir(cwd);
+ fs.mkdirSync(rootDir, { recursive: true });
+ const snapshotCache = new Map();
+
+ function loadCachedOrRebuild(id) {
+ const cached = snapshotCache.get(id);
+ if (cached) return cached;
+ const journalPath = getReadableJournalPath(id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ return rebuilt;
+ }
+
+ 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;
+ }
+
+ return {
+ rootDir,
+ legacyRootDir,
+ appendEvent(event) {
+ const normalized = normalizeEvent(event, sessionId);
+ const journalPath = getJournalPath(rootDir, normalized.id);
+ const snapshotPath = getSnapshotPath(rootDir, normalized.id);
+ const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
+ if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
+ fs.copyFileSync(legacyJournalPath, journalPath);
+ }
+ const prior = loadCachedOrRebuild(normalized.id);
+ const seq = prior.nextSeq;
+ const entry = {
+ seq,
+ 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, prior.diagnostics);
+ snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
+ writeSnapshot(snapshotPath, next);
+ return next;
+ },
+ getSnapshot(id = sessionId, opts = {}) {
+ if (!id) throw new Error('session id required');
+ const journalPath = getReadableJournalPath(id);
+ const snapshotPath = getSnapshotPath(rootDir, id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ writeSnapshot(snapshotPath, rebuilt.snapshot);
+ if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
+ return rebuilt.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));
+ }
+ }
+ return [...ids]
+ .sort()
+ .map((id) => this.getSnapshot(id))
+ .filter(Boolean);
+ },
+ };
+}
+
+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 safeSessionId(id) {
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
+ return id;
+}
+
+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,
+ activeOwner: null,
+ sourceMarkers: {},
+ fallbackMode: null,
+ annotationArtifacts: [],
+ diagnostics: [],
+ updatedAt: 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, inheritedDiagnostics = []) {
+ const event = entry.event || entry;
+ const next = {
+ ...snapshot,
+ paramValues: { ...(snapshot.paramValues || {}) },
+ sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
+ annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
+ diagnostics: [...(snapshot.diagnostics || [])],
+ updatedAt: entry.ts || new Date().toISOString(),
+ };
+
+ if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
+ next.diagnostics = [...inheritedDiagnostics];
+ }
+
+ 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);
+ if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
+ break;
+ case 'variants_ready':
+ case 'agent_done':
+ next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
+ 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.',
+ });
+ }
+ break;
+ case 'checkpoint':
+ if (COMPLETED_PHASES.has(next.phase)) {
+ next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
+ break;
+ }
+ if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
+ next.phase = event.phase ?? next.phase;
+ next.checkpointRevision = event.revision ?? next.checkpointRevision;
+ next.activeOwner = event.owner ?? next.activeOwner;
+ next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
+ next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
+ next.sourceFile = event.sourceFile ?? next.sourceFile;
+ next.previewFile = event.previewFile ?? next.previewFile;
+ next.previewMode = event.previewMode ?? next.previewMode;
+ if (event.paramValues) next.paramValues = { ...event.paramValues };
+ } else {
+ next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
+ }
+ break;
+ case 'accept':
+ case 'accept_intent':
+ next.phase = 'accept_requested';
+ 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 '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.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':
+ 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) {
+ fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
+}
diff --git a/.github/skills/impeccable/scripts/live-svelte-component.mjs b/.github/skills/impeccable/scripts/live/svelte-component.mjs
similarity index 100%
rename from .github/skills/impeccable/scripts/live-svelte-component.mjs
rename to .github/skills/impeccable/scripts/live/svelte-component.mjs
diff --git a/.github/skills/impeccable/scripts/live-sveltekit-adapter.mjs b/.github/skills/impeccable/scripts/live/sveltekit-adapter.mjs
similarity index 100%
rename from .github/skills/impeccable/scripts/live-sveltekit-adapter.mjs
rename to .github/skills/impeccable/scripts/live/sveltekit-adapter.mjs
diff --git a/.github/skills/impeccable/scripts/live-ui-core.mjs b/.github/skills/impeccable/scripts/live/ui-core.mjs
similarity index 100%
rename from .github/skills/impeccable/scripts/live-ui-core.mjs
rename to .github/skills/impeccable/scripts/live/ui-core.mjs
diff --git a/.github/skills/impeccable/scripts/live/vocabulary.mjs b/.github/skills/impeccable/scripts/live/vocabulary.mjs
new file mode 100644
index 000000000..5c7b0b713
--- /dev/null
+++ b/.github/skills/impeccable/scripts/live/vocabulary.mjs
@@ -0,0 +1,36 @@
+/**
+ * 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: `` },
+ { value: 'bolder', label: 'Bolder', icon: `` },
+ { value: 'quieter', label: 'Quieter', icon: `` },
+ { value: 'distill', label: 'Distill', icon: `` },
+ { value: 'polish', label: 'Polish', icon: `` },
+ { value: 'typeset', label: 'Typeset', icon: `` },
+ { value: 'colorize', label: 'Colorize', icon: `` },
+ { value: 'layout', label: 'Layout', icon: `` },
+ { value: 'adapt', label: 'Adapt', icon: `` },
+ { value: 'animate', label: 'Animate', icon: `` },
+ { value: 'delight', label: 'Delight', icon: `` },
+ { value: 'overdrive', label: 'Overdrive', icon: `` },
+];
+
+// Action values accepted by the live event protocol, in palette order.
+export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.kiro/skills/impeccable/scripts/context-signals.mjs b/.kiro/skills/impeccable/scripts/context-signals.mjs
index c03843812..f631d7561 100644
--- a/.kiro/skills/impeccable/scripts/context-signals.mjs
+++ b/.kiro/skills/impeccable/scripts/context-signals.mjs
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
diff --git a/.kiro/skills/impeccable/scripts/critique-storage.mjs b/.kiro/skills/impeccable/scripts/critique-storage.mjs
index eba12383e..645628c8c 100644
--- a/.kiro/skills/impeccable/scripts/critique-storage.mjs
+++ b/.kiro/skills/impeccable/scripts/critique-storage.mjs
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
diff --git a/.kiro/skills/impeccable/scripts/design-parser.mjs b/.kiro/skills/impeccable/scripts/lib/design-parser.mjs
similarity index 100%
rename from .kiro/skills/impeccable/scripts/design-parser.mjs
rename to .kiro/skills/impeccable/scripts/lib/design-parser.mjs
diff --git a/.kiro/skills/impeccable/scripts/impeccable-paths.mjs b/.kiro/skills/impeccable/scripts/lib/impeccable-paths.mjs
similarity index 100%
rename from .kiro/skills/impeccable/scripts/impeccable-paths.mjs
rename to .kiro/skills/impeccable/scripts/lib/impeccable-paths.mjs
diff --git a/.kiro/skills/impeccable/scripts/is-generated.mjs b/.kiro/skills/impeccable/scripts/lib/is-generated.mjs
similarity index 100%
rename from .kiro/skills/impeccable/scripts/is-generated.mjs
rename to .kiro/skills/impeccable/scripts/lib/is-generated.mjs
diff --git a/.kiro/skills/impeccable/scripts/live-accept.mjs b/.kiro/skills/impeccable/scripts/live-accept.mjs
index 5e8687187..278dfbb2e 100644
--- a/.kiro/skills/impeccable/scripts/live-accept.mjs
+++ b/.kiro/skills/impeccable/scripts/live-accept.mjs
@@ -15,14 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.kiro/skills/impeccable/scripts/live-browser.js b/.kiro/skills/impeccable/scripts/live-browser.js
index 0a533945f..9c4b6c21e 100644
--- a/.kiro/skills/impeccable/scripts/live-browser.js
+++ b/.kiro/skills/impeccable/scripts/live-browser.js
@@ -82,7 +82,7 @@
]);
// Command vocabulary (values + labels + icons) comes from the canonical source,
- // skill/scripts/live-vocabulary.mjs, which live-server.mjs serializes into
+ // skill/scripts/live/vocabulary.mjs, which live-server.mjs serializes into
// window.__IMPECCABLE_VOCAB__ when it serves /live.js (same injection path as
// the token/port above, so it is always present here). The icons stack above
// each chip label and recolor to C.brand when selected (strokes use
@@ -1216,7 +1216,7 @@
: (focused ? BP.accentSoft : BP.hairline);
}
- // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs)
+ // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs)
function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
diff --git a/.kiro/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.kiro/skills/impeccable/scripts/live-commit-manual-edits.mjs
index 44bc5ea4b..8bde46b50 100644
--- a/.kiro/skills/impeccable/scripts/live-commit-manual-edits.mjs
+++ b/.kiro/skills/impeccable/scripts/live-commit-manual-edits.mjs
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
-import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
-import { isGeneratedFile } from './is-generated.mjs';
+import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
diff --git a/.kiro/skills/impeccable/scripts/live-complete.mjs b/.kiro/skills/impeccable/scripts/live-complete.mjs
index 78155af85..1cc4e7543 100644
--- a/.kiro/skills/impeccable/scripts/live-complete.mjs
+++ b/.kiro/skills/impeccable/scripts/live-complete.mjs
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
diff --git a/.kiro/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.kiro/skills/impeccable/scripts/live-discard-manual-edits.mjs
index 435f22413..aa669fd20 100644
--- a/.kiro/skills/impeccable/scripts/live-discard-manual-edits.mjs
+++ b/.kiro/skills/impeccable/scripts/live-discard-manual-edits.mjs
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
-import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
+import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
diff --git a/.kiro/skills/impeccable/scripts/live-event-validation.mjs b/.kiro/skills/impeccable/scripts/live-event-validation.mjs
deleted file mode 100644
index 15ae3265d..000000000
--- a/.kiro/skills/impeccable/scripts/live-event-validation.mjs
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * Shared event validation for the live helper server.
- * Extracted for unit testing (insert mode rules).
- */
-
-import { canCreateInsert } from './live-insert-ui.mjs';
-
-// The accepted visual action values come from the canonical vocabulary so the
-// validator, the picker UI, and the marketing demo never drift. Imported (not
-// just re-exported) so it is also in scope for the validators below.
-import { VISUAL_ACTIONS } from './live-vocabulary.mjs';
-export { VISUAL_ACTIONS };
-
-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 = ['<', '{', '}', '`'];
-
-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;
-}
-
-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 '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;
- default:
- return 'Unknown event type: ' + msg.type;
- }
-}
diff --git a/.kiro/skills/impeccable/scripts/live-inject.mjs b/.kiro/skills/impeccable/scripts/live-inject.mjs
index 3a1f36e46..c006ad09e 100644
--- a/.kiro/skills/impeccable/scripts/live-inject.mjs
+++ b/.kiro/skills/impeccable/scripts/live-inject.mjs
@@ -16,12 +16,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { resolveLiveConfigPath } from './impeccable-paths.mjs';
+import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
-} from './live-sveltekit-adapter.mjs';
+} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
diff --git a/.kiro/skills/impeccable/scripts/live-insert.mjs b/.kiro/skills/impeccable/scripts/live-insert.mjs
index 0658e9914..0ed3cafea 100644
--- a/.kiro/skills/impeccable/scripts/live-insert.mjs
+++ b/.kiro/skills/impeccable/scripts/live-insert.mjs
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -25,7 +25,7 @@ import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
diff --git a/.kiro/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.kiro/skills/impeccable/scripts/live-manual-edit-evidence.mjs
index 860278b73..dd10e96dc 100644
--- a/.kiro/skills/impeccable/scripts/live-manual-edit-evidence.mjs
+++ b/.kiro/skills/impeccable/scripts/live-manual-edit-evidence.mjs
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
diff --git a/.kiro/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.kiro/skills/impeccable/scripts/live-manual-edits-buffer.mjs
deleted file mode 100644
index 9e3dcf455..000000000
--- a/.kiro/skills/impeccable/scripts/live-manual-edits-buffer.mjs
+++ /dev/null
@@ -1,152 +0,0 @@
-/**
- * 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 './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;
-}
diff --git a/.kiro/skills/impeccable/scripts/live-poll.mjs b/.kiro/skills/impeccable/scripts/live-poll.mjs
index 1e1259fbc..b031055fe 100644
--- a/.kiro/skills/impeccable/scripts/live-poll.mjs
+++ b/.kiro/skills/impeccable/scripts/live-poll.mjs
@@ -12,8 +12,8 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
diff --git a/.kiro/skills/impeccable/scripts/live-resume.mjs b/.kiro/skills/impeccable/scripts/live-resume.mjs
index e54831f12..74284d48a 100644
--- a/.kiro/skills/impeccable/scripts/live-resume.mjs
+++ b/.kiro/skills/impeccable/scripts/live-resume.mjs
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs
index d9e06f449..187af0ac1 100644
--- a/.kiro/skills/impeccable/scripts/live-server.mjs
+++ b/.kiro/skills/impeccable/scripts/live-server.mjs
@@ -20,11 +20,11 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
-import { parseDesignMd } from './design-parser.mjs';
+import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { validateEvent } from './live-event-validation.mjs';
-import { LIVE_COMMANDS } from './live-vocabulary.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { validateEvent } from './live/event-validation.mjs';
+import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -33,20 +33,20 @@ import {
removeLiveServerInfo,
resolveDesignSidecarPath,
writeLiveServerInfo,
-} from './impeccable-paths.mjs';
+} from './lib/impeccable-paths.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
-} from './live-manual-edits-buffer.mjs';
+} from './live/manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
diff --git a/.kiro/skills/impeccable/scripts/live-session-store.mjs b/.kiro/skills/impeccable/scripts/live-session-store.mjs
deleted file mode 100644
index 5ec4d34d4..000000000
--- a/.kiro/skills/impeccable/scripts/live-session-store.mjs
+++ /dev/null
@@ -1,289 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
-
-const COMPLETED_PHASES = new Set(['completed', 'discarded']);
-
-export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
- const rootDir = getLiveSessionsDir(cwd);
- const legacyRootDir = getLegacyLiveSessionsDir(cwd);
- fs.mkdirSync(rootDir, { recursive: true });
- const snapshotCache = new Map();
-
- function loadCachedOrRebuild(id) {
- const cached = snapshotCache.get(id);
- if (cached) return cached;
- const journalPath = getReadableJournalPath(id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- return rebuilt;
- }
-
- 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;
- }
-
- return {
- rootDir,
- legacyRootDir,
- appendEvent(event) {
- const normalized = normalizeEvent(event, sessionId);
- const journalPath = getJournalPath(rootDir, normalized.id);
- const snapshotPath = getSnapshotPath(rootDir, normalized.id);
- const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
- if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
- fs.copyFileSync(legacyJournalPath, journalPath);
- }
- const prior = loadCachedOrRebuild(normalized.id);
- const seq = prior.nextSeq;
- const entry = {
- seq,
- 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, prior.diagnostics);
- snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
- writeSnapshot(snapshotPath, next);
- return next;
- },
- getSnapshot(id = sessionId, opts = {}) {
- if (!id) throw new Error('session id required');
- const journalPath = getReadableJournalPath(id);
- const snapshotPath = getSnapshotPath(rootDir, id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- writeSnapshot(snapshotPath, rebuilt.snapshot);
- if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
- return rebuilt.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));
- }
- }
- return [...ids]
- .sort()
- .map((id) => this.getSnapshot(id))
- .filter(Boolean);
- },
- };
-}
-
-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 safeSessionId(id) {
- if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
- return id;
-}
-
-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,
- activeOwner: null,
- sourceMarkers: {},
- fallbackMode: null,
- annotationArtifacts: [],
- diagnostics: [],
- updatedAt: 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, inheritedDiagnostics = []) {
- const event = entry.event || entry;
- const next = {
- ...snapshot,
- paramValues: { ...(snapshot.paramValues || {}) },
- sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
- annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
- diagnostics: [...(snapshot.diagnostics || [])],
- updatedAt: entry.ts || new Date().toISOString(),
- };
-
- if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
- next.diagnostics = [...inheritedDiagnostics];
- }
-
- 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);
- if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
- break;
- case 'variants_ready':
- case 'agent_done':
- next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
- 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.',
- });
- }
- break;
- case 'checkpoint':
- if (COMPLETED_PHASES.has(next.phase)) {
- next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
- break;
- }
- if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
- next.phase = event.phase ?? next.phase;
- next.checkpointRevision = event.revision ?? next.checkpointRevision;
- next.activeOwner = event.owner ?? next.activeOwner;
- next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
- next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
- next.sourceFile = event.sourceFile ?? next.sourceFile;
- next.previewFile = event.previewFile ?? next.previewFile;
- next.previewMode = event.previewMode ?? next.previewMode;
- if (event.paramValues) next.paramValues = { ...event.paramValues };
- } else {
- next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
- }
- break;
- case 'accept':
- case 'accept_intent':
- next.phase = 'accept_requested';
- 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 '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.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':
- 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) {
- fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
-}
diff --git a/.kiro/skills/impeccable/scripts/live-status.mjs b/.kiro/skills/impeccable/scripts/live-status.mjs
index a7009cc29..f7e464999 100644
--- a/.kiro/skills/impeccable/scripts/live-status.mjs
+++ b/.kiro/skills/impeccable/scripts/live-status.mjs
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
diff --git a/.kiro/skills/impeccable/scripts/live-vocabulary.mjs b/.kiro/skills/impeccable/scripts/live-vocabulary.mjs
deleted file mode 100644
index 1123d41c4..000000000
--- a/.kiro/skills/impeccable/scripts/live-vocabulary.mjs
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * 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: `` },
- { value: 'bolder', label: 'Bolder', icon: `` },
- { value: 'quieter', label: 'Quieter', icon: `` },
- { value: 'distill', label: 'Distill', icon: `` },
- { value: 'polish', label: 'Polish', icon: `` },
- { value: 'typeset', label: 'Typeset', icon: `` },
- { value: 'colorize', label: 'Colorize', icon: `` },
- { value: 'layout', label: 'Layout', icon: `` },
- { value: 'adapt', label: 'Adapt', icon: `` },
- { value: 'animate', label: 'Animate', icon: `` },
- { value: 'delight', label: 'Delight', icon: `` },
- { value: 'overdrive', label: 'Overdrive', icon: `` },
-];
-
-// Action values accepted by the live event protocol, in palette order.
-export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.kiro/skills/impeccable/scripts/live-wrap.mjs b/.kiro/skills/impeccable/scripts/live-wrap.mjs
index a83cbcc56..c3f00f3a4 100644
--- a/.kiro/skills/impeccable/scripts/live-wrap.mjs
+++ b/.kiro/skills/impeccable/scripts/live-wrap.mjs
@@ -13,13 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.kiro/skills/impeccable/scripts/live.mjs b/.kiro/skills/impeccable/scripts/live.mjs
index 8acd300ed..0992da1bd 100644
--- a/.kiro/skills/impeccable/scripts/live.mjs
+++ b/.kiro/skills/impeccable/scripts/live.mjs
@@ -23,7 +23,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
diff --git a/.kiro/skills/impeccable/scripts/live-completion.mjs b/.kiro/skills/impeccable/scripts/live/completion.mjs
similarity index 100%
rename from .kiro/skills/impeccable/scripts/live-completion.mjs
rename to .kiro/skills/impeccable/scripts/live/completion.mjs
diff --git a/.kiro/skills/impeccable/scripts/live/event-validation.mjs b/.kiro/skills/impeccable/scripts/live/event-validation.mjs
new file mode 100644
index 000000000..0897b1317
--- /dev/null
+++ b/.kiro/skills/impeccable/scripts/live/event-validation.mjs
@@ -0,0 +1,137 @@
+/**
+ * Shared event validation for the live helper server.
+ * Extracted for unit testing (insert mode rules).
+ */
+
+import { canCreateInsert } from './insert-ui.mjs';
+
+// The accepted visual action values come from the canonical vocabulary so the
+// validator, the picker UI, and the marketing demo never drift. Imported (not
+// just re-exported) so it is also in scope for the validators below.
+import { VISUAL_ACTIONS } from './vocabulary.mjs';
+export { VISUAL_ACTIONS };
+
+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 = ['<', '{', '}', '`'];
+
+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;
+}
+
+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 '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;
+ default:
+ return 'Unknown event type: ' + msg.type;
+ }
+}
diff --git a/.kiro/skills/impeccable/scripts/live-insert-ui.mjs b/.kiro/skills/impeccable/scripts/live/insert-ui.mjs
similarity index 100%
rename from .kiro/skills/impeccable/scripts/live-insert-ui.mjs
rename to .kiro/skills/impeccable/scripts/live/insert-ui.mjs
diff --git a/.kiro/skills/impeccable/scripts/live/manual-edits-buffer.mjs b/.kiro/skills/impeccable/scripts/live/manual-edits-buffer.mjs
new file mode 100644
index 000000000..d96ebbe34
--- /dev/null
+++ b/.kiro/skills/impeccable/scripts/live/manual-edits-buffer.mjs
@@ -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;
+}
diff --git a/.kiro/skills/impeccable/scripts/live/session-store.mjs b/.kiro/skills/impeccable/scripts/live/session-store.mjs
new file mode 100644
index 000000000..affba67c9
--- /dev/null
+++ b/.kiro/skills/impeccable/scripts/live/session-store.mjs
@@ -0,0 +1,289 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
+
+const COMPLETED_PHASES = new Set(['completed', 'discarded']);
+
+export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
+ const rootDir = getLiveSessionsDir(cwd);
+ const legacyRootDir = getLegacyLiveSessionsDir(cwd);
+ fs.mkdirSync(rootDir, { recursive: true });
+ const snapshotCache = new Map();
+
+ function loadCachedOrRebuild(id) {
+ const cached = snapshotCache.get(id);
+ if (cached) return cached;
+ const journalPath = getReadableJournalPath(id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ return rebuilt;
+ }
+
+ 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;
+ }
+
+ return {
+ rootDir,
+ legacyRootDir,
+ appendEvent(event) {
+ const normalized = normalizeEvent(event, sessionId);
+ const journalPath = getJournalPath(rootDir, normalized.id);
+ const snapshotPath = getSnapshotPath(rootDir, normalized.id);
+ const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
+ if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
+ fs.copyFileSync(legacyJournalPath, journalPath);
+ }
+ const prior = loadCachedOrRebuild(normalized.id);
+ const seq = prior.nextSeq;
+ const entry = {
+ seq,
+ 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, prior.diagnostics);
+ snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
+ writeSnapshot(snapshotPath, next);
+ return next;
+ },
+ getSnapshot(id = sessionId, opts = {}) {
+ if (!id) throw new Error('session id required');
+ const journalPath = getReadableJournalPath(id);
+ const snapshotPath = getSnapshotPath(rootDir, id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ writeSnapshot(snapshotPath, rebuilt.snapshot);
+ if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
+ return rebuilt.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));
+ }
+ }
+ return [...ids]
+ .sort()
+ .map((id) => this.getSnapshot(id))
+ .filter(Boolean);
+ },
+ };
+}
+
+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 safeSessionId(id) {
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
+ return id;
+}
+
+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,
+ activeOwner: null,
+ sourceMarkers: {},
+ fallbackMode: null,
+ annotationArtifacts: [],
+ diagnostics: [],
+ updatedAt: 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, inheritedDiagnostics = []) {
+ const event = entry.event || entry;
+ const next = {
+ ...snapshot,
+ paramValues: { ...(snapshot.paramValues || {}) },
+ sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
+ annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
+ diagnostics: [...(snapshot.diagnostics || [])],
+ updatedAt: entry.ts || new Date().toISOString(),
+ };
+
+ if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
+ next.diagnostics = [...inheritedDiagnostics];
+ }
+
+ 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);
+ if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
+ break;
+ case 'variants_ready':
+ case 'agent_done':
+ next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
+ 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.',
+ });
+ }
+ break;
+ case 'checkpoint':
+ if (COMPLETED_PHASES.has(next.phase)) {
+ next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
+ break;
+ }
+ if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
+ next.phase = event.phase ?? next.phase;
+ next.checkpointRevision = event.revision ?? next.checkpointRevision;
+ next.activeOwner = event.owner ?? next.activeOwner;
+ next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
+ next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
+ next.sourceFile = event.sourceFile ?? next.sourceFile;
+ next.previewFile = event.previewFile ?? next.previewFile;
+ next.previewMode = event.previewMode ?? next.previewMode;
+ if (event.paramValues) next.paramValues = { ...event.paramValues };
+ } else {
+ next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
+ }
+ break;
+ case 'accept':
+ case 'accept_intent':
+ next.phase = 'accept_requested';
+ 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 '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.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':
+ 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) {
+ fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
+}
diff --git a/.kiro/skills/impeccable/scripts/live-svelte-component.mjs b/.kiro/skills/impeccable/scripts/live/svelte-component.mjs
similarity index 100%
rename from .kiro/skills/impeccable/scripts/live-svelte-component.mjs
rename to .kiro/skills/impeccable/scripts/live/svelte-component.mjs
diff --git a/.kiro/skills/impeccable/scripts/live-sveltekit-adapter.mjs b/.kiro/skills/impeccable/scripts/live/sveltekit-adapter.mjs
similarity index 100%
rename from .kiro/skills/impeccable/scripts/live-sveltekit-adapter.mjs
rename to .kiro/skills/impeccable/scripts/live/sveltekit-adapter.mjs
diff --git a/.kiro/skills/impeccable/scripts/live-ui-core.mjs b/.kiro/skills/impeccable/scripts/live/ui-core.mjs
similarity index 100%
rename from .kiro/skills/impeccable/scripts/live-ui-core.mjs
rename to .kiro/skills/impeccable/scripts/live/ui-core.mjs
diff --git a/.kiro/skills/impeccable/scripts/live/vocabulary.mjs b/.kiro/skills/impeccable/scripts/live/vocabulary.mjs
new file mode 100644
index 000000000..5c7b0b713
--- /dev/null
+++ b/.kiro/skills/impeccable/scripts/live/vocabulary.mjs
@@ -0,0 +1,36 @@
+/**
+ * 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: `` },
+ { value: 'bolder', label: 'Bolder', icon: `` },
+ { value: 'quieter', label: 'Quieter', icon: `` },
+ { value: 'distill', label: 'Distill', icon: `` },
+ { value: 'polish', label: 'Polish', icon: `` },
+ { value: 'typeset', label: 'Typeset', icon: `` },
+ { value: 'colorize', label: 'Colorize', icon: `` },
+ { value: 'layout', label: 'Layout', icon: `` },
+ { value: 'adapt', label: 'Adapt', icon: `` },
+ { value: 'animate', label: 'Animate', icon: `` },
+ { value: 'delight', label: 'Delight', icon: `` },
+ { value: 'overdrive', label: 'Overdrive', icon: `` },
+];
+
+// Action values accepted by the live event protocol, in palette order.
+export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.opencode/skills/impeccable/scripts/context-signals.mjs b/.opencode/skills/impeccable/scripts/context-signals.mjs
index c03843812..f631d7561 100644
--- a/.opencode/skills/impeccable/scripts/context-signals.mjs
+++ b/.opencode/skills/impeccable/scripts/context-signals.mjs
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
diff --git a/.opencode/skills/impeccable/scripts/critique-storage.mjs b/.opencode/skills/impeccable/scripts/critique-storage.mjs
index eba12383e..645628c8c 100644
--- a/.opencode/skills/impeccable/scripts/critique-storage.mjs
+++ b/.opencode/skills/impeccable/scripts/critique-storage.mjs
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
diff --git a/.opencode/skills/impeccable/scripts/design-parser.mjs b/.opencode/skills/impeccable/scripts/lib/design-parser.mjs
similarity index 100%
rename from .opencode/skills/impeccable/scripts/design-parser.mjs
rename to .opencode/skills/impeccable/scripts/lib/design-parser.mjs
diff --git a/.opencode/skills/impeccable/scripts/impeccable-paths.mjs b/.opencode/skills/impeccable/scripts/lib/impeccable-paths.mjs
similarity index 100%
rename from .opencode/skills/impeccable/scripts/impeccable-paths.mjs
rename to .opencode/skills/impeccable/scripts/lib/impeccable-paths.mjs
diff --git a/.opencode/skills/impeccable/scripts/is-generated.mjs b/.opencode/skills/impeccable/scripts/lib/is-generated.mjs
similarity index 100%
rename from .opencode/skills/impeccable/scripts/is-generated.mjs
rename to .opencode/skills/impeccable/scripts/lib/is-generated.mjs
diff --git a/.opencode/skills/impeccable/scripts/live-accept.mjs b/.opencode/skills/impeccable/scripts/live-accept.mjs
index 5e8687187..278dfbb2e 100644
--- a/.opencode/skills/impeccable/scripts/live-accept.mjs
+++ b/.opencode/skills/impeccable/scripts/live-accept.mjs
@@ -15,14 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.opencode/skills/impeccable/scripts/live-browser.js b/.opencode/skills/impeccable/scripts/live-browser.js
index 0a533945f..9c4b6c21e 100644
--- a/.opencode/skills/impeccable/scripts/live-browser.js
+++ b/.opencode/skills/impeccable/scripts/live-browser.js
@@ -82,7 +82,7 @@
]);
// Command vocabulary (values + labels + icons) comes from the canonical source,
- // skill/scripts/live-vocabulary.mjs, which live-server.mjs serializes into
+ // skill/scripts/live/vocabulary.mjs, which live-server.mjs serializes into
// window.__IMPECCABLE_VOCAB__ when it serves /live.js (same injection path as
// the token/port above, so it is always present here). The icons stack above
// each chip label and recolor to C.brand when selected (strokes use
@@ -1216,7 +1216,7 @@
: (focused ? BP.accentSoft : BP.hairline);
}
- // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs)
+ // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs)
function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
diff --git a/.opencode/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.opencode/skills/impeccable/scripts/live-commit-manual-edits.mjs
index 44bc5ea4b..8bde46b50 100644
--- a/.opencode/skills/impeccable/scripts/live-commit-manual-edits.mjs
+++ b/.opencode/skills/impeccable/scripts/live-commit-manual-edits.mjs
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
-import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
-import { isGeneratedFile } from './is-generated.mjs';
+import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
diff --git a/.opencode/skills/impeccable/scripts/live-complete.mjs b/.opencode/skills/impeccable/scripts/live-complete.mjs
index 78155af85..1cc4e7543 100644
--- a/.opencode/skills/impeccable/scripts/live-complete.mjs
+++ b/.opencode/skills/impeccable/scripts/live-complete.mjs
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
diff --git a/.opencode/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.opencode/skills/impeccable/scripts/live-discard-manual-edits.mjs
index 435f22413..aa669fd20 100644
--- a/.opencode/skills/impeccable/scripts/live-discard-manual-edits.mjs
+++ b/.opencode/skills/impeccable/scripts/live-discard-manual-edits.mjs
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
-import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
+import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
diff --git a/.opencode/skills/impeccable/scripts/live-event-validation.mjs b/.opencode/skills/impeccable/scripts/live-event-validation.mjs
deleted file mode 100644
index 15ae3265d..000000000
--- a/.opencode/skills/impeccable/scripts/live-event-validation.mjs
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * Shared event validation for the live helper server.
- * Extracted for unit testing (insert mode rules).
- */
-
-import { canCreateInsert } from './live-insert-ui.mjs';
-
-// The accepted visual action values come from the canonical vocabulary so the
-// validator, the picker UI, and the marketing demo never drift. Imported (not
-// just re-exported) so it is also in scope for the validators below.
-import { VISUAL_ACTIONS } from './live-vocabulary.mjs';
-export { VISUAL_ACTIONS };
-
-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 = ['<', '{', '}', '`'];
-
-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;
-}
-
-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 '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;
- default:
- return 'Unknown event type: ' + msg.type;
- }
-}
diff --git a/.opencode/skills/impeccable/scripts/live-inject.mjs b/.opencode/skills/impeccable/scripts/live-inject.mjs
index 3a1f36e46..c006ad09e 100644
--- a/.opencode/skills/impeccable/scripts/live-inject.mjs
+++ b/.opencode/skills/impeccable/scripts/live-inject.mjs
@@ -16,12 +16,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { resolveLiveConfigPath } from './impeccable-paths.mjs';
+import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
-} from './live-sveltekit-adapter.mjs';
+} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
diff --git a/.opencode/skills/impeccable/scripts/live-insert.mjs b/.opencode/skills/impeccable/scripts/live-insert.mjs
index 0658e9914..0ed3cafea 100644
--- a/.opencode/skills/impeccable/scripts/live-insert.mjs
+++ b/.opencode/skills/impeccable/scripts/live-insert.mjs
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -25,7 +25,7 @@ import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
diff --git a/.opencode/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.opencode/skills/impeccable/scripts/live-manual-edit-evidence.mjs
index 860278b73..dd10e96dc 100644
--- a/.opencode/skills/impeccable/scripts/live-manual-edit-evidence.mjs
+++ b/.opencode/skills/impeccable/scripts/live-manual-edit-evidence.mjs
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
diff --git a/.opencode/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.opencode/skills/impeccable/scripts/live-manual-edits-buffer.mjs
deleted file mode 100644
index 9e3dcf455..000000000
--- a/.opencode/skills/impeccable/scripts/live-manual-edits-buffer.mjs
+++ /dev/null
@@ -1,152 +0,0 @@
-/**
- * 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 './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;
-}
diff --git a/.opencode/skills/impeccable/scripts/live-poll.mjs b/.opencode/skills/impeccable/scripts/live-poll.mjs
index 1e1259fbc..b031055fe 100644
--- a/.opencode/skills/impeccable/scripts/live-poll.mjs
+++ b/.opencode/skills/impeccable/scripts/live-poll.mjs
@@ -12,8 +12,8 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
diff --git a/.opencode/skills/impeccable/scripts/live-resume.mjs b/.opencode/skills/impeccable/scripts/live-resume.mjs
index e54831f12..74284d48a 100644
--- a/.opencode/skills/impeccable/scripts/live-resume.mjs
+++ b/.opencode/skills/impeccable/scripts/live-resume.mjs
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs
index d9e06f449..187af0ac1 100644
--- a/.opencode/skills/impeccable/scripts/live-server.mjs
+++ b/.opencode/skills/impeccable/scripts/live-server.mjs
@@ -20,11 +20,11 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
-import { parseDesignMd } from './design-parser.mjs';
+import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { validateEvent } from './live-event-validation.mjs';
-import { LIVE_COMMANDS } from './live-vocabulary.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { validateEvent } from './live/event-validation.mjs';
+import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -33,20 +33,20 @@ import {
removeLiveServerInfo,
resolveDesignSidecarPath,
writeLiveServerInfo,
-} from './impeccable-paths.mjs';
+} from './lib/impeccable-paths.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
-} from './live-manual-edits-buffer.mjs';
+} from './live/manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
diff --git a/.opencode/skills/impeccable/scripts/live-session-store.mjs b/.opencode/skills/impeccable/scripts/live-session-store.mjs
deleted file mode 100644
index 5ec4d34d4..000000000
--- a/.opencode/skills/impeccable/scripts/live-session-store.mjs
+++ /dev/null
@@ -1,289 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
-
-const COMPLETED_PHASES = new Set(['completed', 'discarded']);
-
-export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
- const rootDir = getLiveSessionsDir(cwd);
- const legacyRootDir = getLegacyLiveSessionsDir(cwd);
- fs.mkdirSync(rootDir, { recursive: true });
- const snapshotCache = new Map();
-
- function loadCachedOrRebuild(id) {
- const cached = snapshotCache.get(id);
- if (cached) return cached;
- const journalPath = getReadableJournalPath(id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- return rebuilt;
- }
-
- 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;
- }
-
- return {
- rootDir,
- legacyRootDir,
- appendEvent(event) {
- const normalized = normalizeEvent(event, sessionId);
- const journalPath = getJournalPath(rootDir, normalized.id);
- const snapshotPath = getSnapshotPath(rootDir, normalized.id);
- const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
- if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
- fs.copyFileSync(legacyJournalPath, journalPath);
- }
- const prior = loadCachedOrRebuild(normalized.id);
- const seq = prior.nextSeq;
- const entry = {
- seq,
- 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, prior.diagnostics);
- snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
- writeSnapshot(snapshotPath, next);
- return next;
- },
- getSnapshot(id = sessionId, opts = {}) {
- if (!id) throw new Error('session id required');
- const journalPath = getReadableJournalPath(id);
- const snapshotPath = getSnapshotPath(rootDir, id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- writeSnapshot(snapshotPath, rebuilt.snapshot);
- if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
- return rebuilt.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));
- }
- }
- return [...ids]
- .sort()
- .map((id) => this.getSnapshot(id))
- .filter(Boolean);
- },
- };
-}
-
-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 safeSessionId(id) {
- if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
- return id;
-}
-
-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,
- activeOwner: null,
- sourceMarkers: {},
- fallbackMode: null,
- annotationArtifacts: [],
- diagnostics: [],
- updatedAt: 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, inheritedDiagnostics = []) {
- const event = entry.event || entry;
- const next = {
- ...snapshot,
- paramValues: { ...(snapshot.paramValues || {}) },
- sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
- annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
- diagnostics: [...(snapshot.diagnostics || [])],
- updatedAt: entry.ts || new Date().toISOString(),
- };
-
- if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
- next.diagnostics = [...inheritedDiagnostics];
- }
-
- 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);
- if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
- break;
- case 'variants_ready':
- case 'agent_done':
- next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
- 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.',
- });
- }
- break;
- case 'checkpoint':
- if (COMPLETED_PHASES.has(next.phase)) {
- next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
- break;
- }
- if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
- next.phase = event.phase ?? next.phase;
- next.checkpointRevision = event.revision ?? next.checkpointRevision;
- next.activeOwner = event.owner ?? next.activeOwner;
- next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
- next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
- next.sourceFile = event.sourceFile ?? next.sourceFile;
- next.previewFile = event.previewFile ?? next.previewFile;
- next.previewMode = event.previewMode ?? next.previewMode;
- if (event.paramValues) next.paramValues = { ...event.paramValues };
- } else {
- next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
- }
- break;
- case 'accept':
- case 'accept_intent':
- next.phase = 'accept_requested';
- 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 '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.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':
- 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) {
- fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
-}
diff --git a/.opencode/skills/impeccable/scripts/live-status.mjs b/.opencode/skills/impeccable/scripts/live-status.mjs
index a7009cc29..f7e464999 100644
--- a/.opencode/skills/impeccable/scripts/live-status.mjs
+++ b/.opencode/skills/impeccable/scripts/live-status.mjs
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
diff --git a/.opencode/skills/impeccable/scripts/live-vocabulary.mjs b/.opencode/skills/impeccable/scripts/live-vocabulary.mjs
deleted file mode 100644
index 1123d41c4..000000000
--- a/.opencode/skills/impeccable/scripts/live-vocabulary.mjs
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * 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: `` },
- { value: 'bolder', label: 'Bolder', icon: `` },
- { value: 'quieter', label: 'Quieter', icon: `` },
- { value: 'distill', label: 'Distill', icon: `` },
- { value: 'polish', label: 'Polish', icon: `` },
- { value: 'typeset', label: 'Typeset', icon: `` },
- { value: 'colorize', label: 'Colorize', icon: `` },
- { value: 'layout', label: 'Layout', icon: `` },
- { value: 'adapt', label: 'Adapt', icon: `` },
- { value: 'animate', label: 'Animate', icon: `` },
- { value: 'delight', label: 'Delight', icon: `` },
- { value: 'overdrive', label: 'Overdrive', icon: `` },
-];
-
-// Action values accepted by the live event protocol, in palette order.
-export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.opencode/skills/impeccable/scripts/live-wrap.mjs b/.opencode/skills/impeccable/scripts/live-wrap.mjs
index a83cbcc56..c3f00f3a4 100644
--- a/.opencode/skills/impeccable/scripts/live-wrap.mjs
+++ b/.opencode/skills/impeccable/scripts/live-wrap.mjs
@@ -13,13 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.opencode/skills/impeccable/scripts/live.mjs b/.opencode/skills/impeccable/scripts/live.mjs
index 8acd300ed..0992da1bd 100644
--- a/.opencode/skills/impeccable/scripts/live.mjs
+++ b/.opencode/skills/impeccable/scripts/live.mjs
@@ -23,7 +23,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
diff --git a/.opencode/skills/impeccable/scripts/live-completion.mjs b/.opencode/skills/impeccable/scripts/live/completion.mjs
similarity index 100%
rename from .opencode/skills/impeccable/scripts/live-completion.mjs
rename to .opencode/skills/impeccable/scripts/live/completion.mjs
diff --git a/.opencode/skills/impeccable/scripts/live/event-validation.mjs b/.opencode/skills/impeccable/scripts/live/event-validation.mjs
new file mode 100644
index 000000000..0897b1317
--- /dev/null
+++ b/.opencode/skills/impeccable/scripts/live/event-validation.mjs
@@ -0,0 +1,137 @@
+/**
+ * Shared event validation for the live helper server.
+ * Extracted for unit testing (insert mode rules).
+ */
+
+import { canCreateInsert } from './insert-ui.mjs';
+
+// The accepted visual action values come from the canonical vocabulary so the
+// validator, the picker UI, and the marketing demo never drift. Imported (not
+// just re-exported) so it is also in scope for the validators below.
+import { VISUAL_ACTIONS } from './vocabulary.mjs';
+export { VISUAL_ACTIONS };
+
+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 = ['<', '{', '}', '`'];
+
+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;
+}
+
+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 '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;
+ default:
+ return 'Unknown event type: ' + msg.type;
+ }
+}
diff --git a/.opencode/skills/impeccable/scripts/live-insert-ui.mjs b/.opencode/skills/impeccable/scripts/live/insert-ui.mjs
similarity index 100%
rename from .opencode/skills/impeccable/scripts/live-insert-ui.mjs
rename to .opencode/skills/impeccable/scripts/live/insert-ui.mjs
diff --git a/.opencode/skills/impeccable/scripts/live/manual-edits-buffer.mjs b/.opencode/skills/impeccable/scripts/live/manual-edits-buffer.mjs
new file mode 100644
index 000000000..d96ebbe34
--- /dev/null
+++ b/.opencode/skills/impeccable/scripts/live/manual-edits-buffer.mjs
@@ -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;
+}
diff --git a/.opencode/skills/impeccable/scripts/live/session-store.mjs b/.opencode/skills/impeccable/scripts/live/session-store.mjs
new file mode 100644
index 000000000..affba67c9
--- /dev/null
+++ b/.opencode/skills/impeccable/scripts/live/session-store.mjs
@@ -0,0 +1,289 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
+
+const COMPLETED_PHASES = new Set(['completed', 'discarded']);
+
+export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
+ const rootDir = getLiveSessionsDir(cwd);
+ const legacyRootDir = getLegacyLiveSessionsDir(cwd);
+ fs.mkdirSync(rootDir, { recursive: true });
+ const snapshotCache = new Map();
+
+ function loadCachedOrRebuild(id) {
+ const cached = snapshotCache.get(id);
+ if (cached) return cached;
+ const journalPath = getReadableJournalPath(id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ return rebuilt;
+ }
+
+ 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;
+ }
+
+ return {
+ rootDir,
+ legacyRootDir,
+ appendEvent(event) {
+ const normalized = normalizeEvent(event, sessionId);
+ const journalPath = getJournalPath(rootDir, normalized.id);
+ const snapshotPath = getSnapshotPath(rootDir, normalized.id);
+ const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
+ if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
+ fs.copyFileSync(legacyJournalPath, journalPath);
+ }
+ const prior = loadCachedOrRebuild(normalized.id);
+ const seq = prior.nextSeq;
+ const entry = {
+ seq,
+ 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, prior.diagnostics);
+ snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
+ writeSnapshot(snapshotPath, next);
+ return next;
+ },
+ getSnapshot(id = sessionId, opts = {}) {
+ if (!id) throw new Error('session id required');
+ const journalPath = getReadableJournalPath(id);
+ const snapshotPath = getSnapshotPath(rootDir, id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ writeSnapshot(snapshotPath, rebuilt.snapshot);
+ if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
+ return rebuilt.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));
+ }
+ }
+ return [...ids]
+ .sort()
+ .map((id) => this.getSnapshot(id))
+ .filter(Boolean);
+ },
+ };
+}
+
+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 safeSessionId(id) {
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
+ return id;
+}
+
+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,
+ activeOwner: null,
+ sourceMarkers: {},
+ fallbackMode: null,
+ annotationArtifacts: [],
+ diagnostics: [],
+ updatedAt: 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, inheritedDiagnostics = []) {
+ const event = entry.event || entry;
+ const next = {
+ ...snapshot,
+ paramValues: { ...(snapshot.paramValues || {}) },
+ sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
+ annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
+ diagnostics: [...(snapshot.diagnostics || [])],
+ updatedAt: entry.ts || new Date().toISOString(),
+ };
+
+ if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
+ next.diagnostics = [...inheritedDiagnostics];
+ }
+
+ 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);
+ if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
+ break;
+ case 'variants_ready':
+ case 'agent_done':
+ next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
+ 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.',
+ });
+ }
+ break;
+ case 'checkpoint':
+ if (COMPLETED_PHASES.has(next.phase)) {
+ next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
+ break;
+ }
+ if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
+ next.phase = event.phase ?? next.phase;
+ next.checkpointRevision = event.revision ?? next.checkpointRevision;
+ next.activeOwner = event.owner ?? next.activeOwner;
+ next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
+ next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
+ next.sourceFile = event.sourceFile ?? next.sourceFile;
+ next.previewFile = event.previewFile ?? next.previewFile;
+ next.previewMode = event.previewMode ?? next.previewMode;
+ if (event.paramValues) next.paramValues = { ...event.paramValues };
+ } else {
+ next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
+ }
+ break;
+ case 'accept':
+ case 'accept_intent':
+ next.phase = 'accept_requested';
+ 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 '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.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':
+ 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) {
+ fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
+}
diff --git a/.opencode/skills/impeccable/scripts/live-svelte-component.mjs b/.opencode/skills/impeccable/scripts/live/svelte-component.mjs
similarity index 100%
rename from .opencode/skills/impeccable/scripts/live-svelte-component.mjs
rename to .opencode/skills/impeccable/scripts/live/svelte-component.mjs
diff --git a/.opencode/skills/impeccable/scripts/live-sveltekit-adapter.mjs b/.opencode/skills/impeccable/scripts/live/sveltekit-adapter.mjs
similarity index 100%
rename from .opencode/skills/impeccable/scripts/live-sveltekit-adapter.mjs
rename to .opencode/skills/impeccable/scripts/live/sveltekit-adapter.mjs
diff --git a/.opencode/skills/impeccable/scripts/live-ui-core.mjs b/.opencode/skills/impeccable/scripts/live/ui-core.mjs
similarity index 100%
rename from .opencode/skills/impeccable/scripts/live-ui-core.mjs
rename to .opencode/skills/impeccable/scripts/live/ui-core.mjs
diff --git a/.opencode/skills/impeccable/scripts/live/vocabulary.mjs b/.opencode/skills/impeccable/scripts/live/vocabulary.mjs
new file mode 100644
index 000000000..5c7b0b713
--- /dev/null
+++ b/.opencode/skills/impeccable/scripts/live/vocabulary.mjs
@@ -0,0 +1,36 @@
+/**
+ * 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: `` },
+ { value: 'bolder', label: 'Bolder', icon: `` },
+ { value: 'quieter', label: 'Quieter', icon: `` },
+ { value: 'distill', label: 'Distill', icon: `` },
+ { value: 'polish', label: 'Polish', icon: `` },
+ { value: 'typeset', label: 'Typeset', icon: `` },
+ { value: 'colorize', label: 'Colorize', icon: `` },
+ { value: 'layout', label: 'Layout', icon: `` },
+ { value: 'adapt', label: 'Adapt', icon: `` },
+ { value: 'animate', label: 'Animate', icon: `` },
+ { value: 'delight', label: 'Delight', icon: `` },
+ { value: 'overdrive', label: 'Overdrive', icon: `` },
+];
+
+// Action values accepted by the live event protocol, in palette order.
+export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.pi/skills/impeccable/scripts/context-signals.mjs b/.pi/skills/impeccable/scripts/context-signals.mjs
index c03843812..f631d7561 100644
--- a/.pi/skills/impeccable/scripts/context-signals.mjs
+++ b/.pi/skills/impeccable/scripts/context-signals.mjs
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
diff --git a/.pi/skills/impeccable/scripts/critique-storage.mjs b/.pi/skills/impeccable/scripts/critique-storage.mjs
index eba12383e..645628c8c 100644
--- a/.pi/skills/impeccable/scripts/critique-storage.mjs
+++ b/.pi/skills/impeccable/scripts/critique-storage.mjs
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
diff --git a/.pi/skills/impeccable/scripts/design-parser.mjs b/.pi/skills/impeccable/scripts/lib/design-parser.mjs
similarity index 100%
rename from .pi/skills/impeccable/scripts/design-parser.mjs
rename to .pi/skills/impeccable/scripts/lib/design-parser.mjs
diff --git a/.pi/skills/impeccable/scripts/impeccable-paths.mjs b/.pi/skills/impeccable/scripts/lib/impeccable-paths.mjs
similarity index 100%
rename from .pi/skills/impeccable/scripts/impeccable-paths.mjs
rename to .pi/skills/impeccable/scripts/lib/impeccable-paths.mjs
diff --git a/.pi/skills/impeccable/scripts/is-generated.mjs b/.pi/skills/impeccable/scripts/lib/is-generated.mjs
similarity index 100%
rename from .pi/skills/impeccable/scripts/is-generated.mjs
rename to .pi/skills/impeccable/scripts/lib/is-generated.mjs
diff --git a/.pi/skills/impeccable/scripts/live-accept.mjs b/.pi/skills/impeccable/scripts/live-accept.mjs
index 5e8687187..278dfbb2e 100644
--- a/.pi/skills/impeccable/scripts/live-accept.mjs
+++ b/.pi/skills/impeccable/scripts/live-accept.mjs
@@ -15,14 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.pi/skills/impeccable/scripts/live-browser.js b/.pi/skills/impeccable/scripts/live-browser.js
index 0a533945f..9c4b6c21e 100644
--- a/.pi/skills/impeccable/scripts/live-browser.js
+++ b/.pi/skills/impeccable/scripts/live-browser.js
@@ -82,7 +82,7 @@
]);
// Command vocabulary (values + labels + icons) comes from the canonical source,
- // skill/scripts/live-vocabulary.mjs, which live-server.mjs serializes into
+ // skill/scripts/live/vocabulary.mjs, which live-server.mjs serializes into
// window.__IMPECCABLE_VOCAB__ when it serves /live.js (same injection path as
// the token/port above, so it is always present here). The icons stack above
// each chip label and recolor to C.brand when selected (strokes use
@@ -1216,7 +1216,7 @@
: (focused ? BP.accentSoft : BP.hairline);
}
- // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs)
+ // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs)
function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
diff --git a/.pi/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.pi/skills/impeccable/scripts/live-commit-manual-edits.mjs
index 44bc5ea4b..8bde46b50 100644
--- a/.pi/skills/impeccable/scripts/live-commit-manual-edits.mjs
+++ b/.pi/skills/impeccable/scripts/live-commit-manual-edits.mjs
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
-import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
-import { isGeneratedFile } from './is-generated.mjs';
+import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
diff --git a/.pi/skills/impeccable/scripts/live-complete.mjs b/.pi/skills/impeccable/scripts/live-complete.mjs
index 78155af85..1cc4e7543 100644
--- a/.pi/skills/impeccable/scripts/live-complete.mjs
+++ b/.pi/skills/impeccable/scripts/live-complete.mjs
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
diff --git a/.pi/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.pi/skills/impeccable/scripts/live-discard-manual-edits.mjs
index 435f22413..aa669fd20 100644
--- a/.pi/skills/impeccable/scripts/live-discard-manual-edits.mjs
+++ b/.pi/skills/impeccable/scripts/live-discard-manual-edits.mjs
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
-import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
+import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
diff --git a/.pi/skills/impeccable/scripts/live-event-validation.mjs b/.pi/skills/impeccable/scripts/live-event-validation.mjs
deleted file mode 100644
index 15ae3265d..000000000
--- a/.pi/skills/impeccable/scripts/live-event-validation.mjs
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * Shared event validation for the live helper server.
- * Extracted for unit testing (insert mode rules).
- */
-
-import { canCreateInsert } from './live-insert-ui.mjs';
-
-// The accepted visual action values come from the canonical vocabulary so the
-// validator, the picker UI, and the marketing demo never drift. Imported (not
-// just re-exported) so it is also in scope for the validators below.
-import { VISUAL_ACTIONS } from './live-vocabulary.mjs';
-export { VISUAL_ACTIONS };
-
-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 = ['<', '{', '}', '`'];
-
-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;
-}
-
-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 '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;
- default:
- return 'Unknown event type: ' + msg.type;
- }
-}
diff --git a/.pi/skills/impeccable/scripts/live-inject.mjs b/.pi/skills/impeccable/scripts/live-inject.mjs
index 3a1f36e46..c006ad09e 100644
--- a/.pi/skills/impeccable/scripts/live-inject.mjs
+++ b/.pi/skills/impeccable/scripts/live-inject.mjs
@@ -16,12 +16,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { resolveLiveConfigPath } from './impeccable-paths.mjs';
+import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
-} from './live-sveltekit-adapter.mjs';
+} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
diff --git a/.pi/skills/impeccable/scripts/live-insert.mjs b/.pi/skills/impeccable/scripts/live-insert.mjs
index 0658e9914..0ed3cafea 100644
--- a/.pi/skills/impeccable/scripts/live-insert.mjs
+++ b/.pi/skills/impeccable/scripts/live-insert.mjs
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -25,7 +25,7 @@ import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
diff --git a/.pi/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.pi/skills/impeccable/scripts/live-manual-edit-evidence.mjs
index 860278b73..dd10e96dc 100644
--- a/.pi/skills/impeccable/scripts/live-manual-edit-evidence.mjs
+++ b/.pi/skills/impeccable/scripts/live-manual-edit-evidence.mjs
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
diff --git a/.pi/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.pi/skills/impeccable/scripts/live-manual-edits-buffer.mjs
deleted file mode 100644
index 9e3dcf455..000000000
--- a/.pi/skills/impeccable/scripts/live-manual-edits-buffer.mjs
+++ /dev/null
@@ -1,152 +0,0 @@
-/**
- * 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 './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;
-}
diff --git a/.pi/skills/impeccable/scripts/live-poll.mjs b/.pi/skills/impeccable/scripts/live-poll.mjs
index 1e1259fbc..b031055fe 100644
--- a/.pi/skills/impeccable/scripts/live-poll.mjs
+++ b/.pi/skills/impeccable/scripts/live-poll.mjs
@@ -12,8 +12,8 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
diff --git a/.pi/skills/impeccable/scripts/live-resume.mjs b/.pi/skills/impeccable/scripts/live-resume.mjs
index e54831f12..74284d48a 100644
--- a/.pi/skills/impeccable/scripts/live-resume.mjs
+++ b/.pi/skills/impeccable/scripts/live-resume.mjs
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs
index d9e06f449..187af0ac1 100644
--- a/.pi/skills/impeccable/scripts/live-server.mjs
+++ b/.pi/skills/impeccable/scripts/live-server.mjs
@@ -20,11 +20,11 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
-import { parseDesignMd } from './design-parser.mjs';
+import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { validateEvent } from './live-event-validation.mjs';
-import { LIVE_COMMANDS } from './live-vocabulary.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { validateEvent } from './live/event-validation.mjs';
+import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -33,20 +33,20 @@ import {
removeLiveServerInfo,
resolveDesignSidecarPath,
writeLiveServerInfo,
-} from './impeccable-paths.mjs';
+} from './lib/impeccable-paths.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
-} from './live-manual-edits-buffer.mjs';
+} from './live/manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
diff --git a/.pi/skills/impeccable/scripts/live-session-store.mjs b/.pi/skills/impeccable/scripts/live-session-store.mjs
deleted file mode 100644
index 5ec4d34d4..000000000
--- a/.pi/skills/impeccable/scripts/live-session-store.mjs
+++ /dev/null
@@ -1,289 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
-
-const COMPLETED_PHASES = new Set(['completed', 'discarded']);
-
-export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
- const rootDir = getLiveSessionsDir(cwd);
- const legacyRootDir = getLegacyLiveSessionsDir(cwd);
- fs.mkdirSync(rootDir, { recursive: true });
- const snapshotCache = new Map();
-
- function loadCachedOrRebuild(id) {
- const cached = snapshotCache.get(id);
- if (cached) return cached;
- const journalPath = getReadableJournalPath(id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- return rebuilt;
- }
-
- 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;
- }
-
- return {
- rootDir,
- legacyRootDir,
- appendEvent(event) {
- const normalized = normalizeEvent(event, sessionId);
- const journalPath = getJournalPath(rootDir, normalized.id);
- const snapshotPath = getSnapshotPath(rootDir, normalized.id);
- const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
- if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
- fs.copyFileSync(legacyJournalPath, journalPath);
- }
- const prior = loadCachedOrRebuild(normalized.id);
- const seq = prior.nextSeq;
- const entry = {
- seq,
- 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, prior.diagnostics);
- snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
- writeSnapshot(snapshotPath, next);
- return next;
- },
- getSnapshot(id = sessionId, opts = {}) {
- if (!id) throw new Error('session id required');
- const journalPath = getReadableJournalPath(id);
- const snapshotPath = getSnapshotPath(rootDir, id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- writeSnapshot(snapshotPath, rebuilt.snapshot);
- if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
- return rebuilt.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));
- }
- }
- return [...ids]
- .sort()
- .map((id) => this.getSnapshot(id))
- .filter(Boolean);
- },
- };
-}
-
-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 safeSessionId(id) {
- if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
- return id;
-}
-
-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,
- activeOwner: null,
- sourceMarkers: {},
- fallbackMode: null,
- annotationArtifacts: [],
- diagnostics: [],
- updatedAt: 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, inheritedDiagnostics = []) {
- const event = entry.event || entry;
- const next = {
- ...snapshot,
- paramValues: { ...(snapshot.paramValues || {}) },
- sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
- annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
- diagnostics: [...(snapshot.diagnostics || [])],
- updatedAt: entry.ts || new Date().toISOString(),
- };
-
- if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
- next.diagnostics = [...inheritedDiagnostics];
- }
-
- 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);
- if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
- break;
- case 'variants_ready':
- case 'agent_done':
- next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
- 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.',
- });
- }
- break;
- case 'checkpoint':
- if (COMPLETED_PHASES.has(next.phase)) {
- next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
- break;
- }
- if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
- next.phase = event.phase ?? next.phase;
- next.checkpointRevision = event.revision ?? next.checkpointRevision;
- next.activeOwner = event.owner ?? next.activeOwner;
- next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
- next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
- next.sourceFile = event.sourceFile ?? next.sourceFile;
- next.previewFile = event.previewFile ?? next.previewFile;
- next.previewMode = event.previewMode ?? next.previewMode;
- if (event.paramValues) next.paramValues = { ...event.paramValues };
- } else {
- next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
- }
- break;
- case 'accept':
- case 'accept_intent':
- next.phase = 'accept_requested';
- 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 '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.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':
- 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) {
- fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
-}
diff --git a/.pi/skills/impeccable/scripts/live-status.mjs b/.pi/skills/impeccable/scripts/live-status.mjs
index a7009cc29..f7e464999 100644
--- a/.pi/skills/impeccable/scripts/live-status.mjs
+++ b/.pi/skills/impeccable/scripts/live-status.mjs
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
diff --git a/.pi/skills/impeccable/scripts/live-vocabulary.mjs b/.pi/skills/impeccable/scripts/live-vocabulary.mjs
deleted file mode 100644
index 1123d41c4..000000000
--- a/.pi/skills/impeccable/scripts/live-vocabulary.mjs
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * 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: `` },
- { value: 'bolder', label: 'Bolder', icon: `` },
- { value: 'quieter', label: 'Quieter', icon: `` },
- { value: 'distill', label: 'Distill', icon: `` },
- { value: 'polish', label: 'Polish', icon: `` },
- { value: 'typeset', label: 'Typeset', icon: `` },
- { value: 'colorize', label: 'Colorize', icon: `` },
- { value: 'layout', label: 'Layout', icon: `` },
- { value: 'adapt', label: 'Adapt', icon: `` },
- { value: 'animate', label: 'Animate', icon: `` },
- { value: 'delight', label: 'Delight', icon: `` },
- { value: 'overdrive', label: 'Overdrive', icon: `` },
-];
-
-// Action values accepted by the live event protocol, in palette order.
-export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.pi/skills/impeccable/scripts/live-wrap.mjs b/.pi/skills/impeccable/scripts/live-wrap.mjs
index a83cbcc56..c3f00f3a4 100644
--- a/.pi/skills/impeccable/scripts/live-wrap.mjs
+++ b/.pi/skills/impeccable/scripts/live-wrap.mjs
@@ -13,13 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.pi/skills/impeccable/scripts/live.mjs b/.pi/skills/impeccable/scripts/live.mjs
index 8acd300ed..0992da1bd 100644
--- a/.pi/skills/impeccable/scripts/live.mjs
+++ b/.pi/skills/impeccable/scripts/live.mjs
@@ -23,7 +23,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
diff --git a/.pi/skills/impeccable/scripts/live-completion.mjs b/.pi/skills/impeccable/scripts/live/completion.mjs
similarity index 100%
rename from .pi/skills/impeccable/scripts/live-completion.mjs
rename to .pi/skills/impeccable/scripts/live/completion.mjs
diff --git a/.pi/skills/impeccable/scripts/live/event-validation.mjs b/.pi/skills/impeccable/scripts/live/event-validation.mjs
new file mode 100644
index 000000000..0897b1317
--- /dev/null
+++ b/.pi/skills/impeccable/scripts/live/event-validation.mjs
@@ -0,0 +1,137 @@
+/**
+ * Shared event validation for the live helper server.
+ * Extracted for unit testing (insert mode rules).
+ */
+
+import { canCreateInsert } from './insert-ui.mjs';
+
+// The accepted visual action values come from the canonical vocabulary so the
+// validator, the picker UI, and the marketing demo never drift. Imported (not
+// just re-exported) so it is also in scope for the validators below.
+import { VISUAL_ACTIONS } from './vocabulary.mjs';
+export { VISUAL_ACTIONS };
+
+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 = ['<', '{', '}', '`'];
+
+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;
+}
+
+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 '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;
+ default:
+ return 'Unknown event type: ' + msg.type;
+ }
+}
diff --git a/.pi/skills/impeccable/scripts/live-insert-ui.mjs b/.pi/skills/impeccable/scripts/live/insert-ui.mjs
similarity index 100%
rename from .pi/skills/impeccable/scripts/live-insert-ui.mjs
rename to .pi/skills/impeccable/scripts/live/insert-ui.mjs
diff --git a/.pi/skills/impeccable/scripts/live/manual-edits-buffer.mjs b/.pi/skills/impeccable/scripts/live/manual-edits-buffer.mjs
new file mode 100644
index 000000000..d96ebbe34
--- /dev/null
+++ b/.pi/skills/impeccable/scripts/live/manual-edits-buffer.mjs
@@ -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;
+}
diff --git a/.pi/skills/impeccable/scripts/live/session-store.mjs b/.pi/skills/impeccable/scripts/live/session-store.mjs
new file mode 100644
index 000000000..affba67c9
--- /dev/null
+++ b/.pi/skills/impeccable/scripts/live/session-store.mjs
@@ -0,0 +1,289 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
+
+const COMPLETED_PHASES = new Set(['completed', 'discarded']);
+
+export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
+ const rootDir = getLiveSessionsDir(cwd);
+ const legacyRootDir = getLegacyLiveSessionsDir(cwd);
+ fs.mkdirSync(rootDir, { recursive: true });
+ const snapshotCache = new Map();
+
+ function loadCachedOrRebuild(id) {
+ const cached = snapshotCache.get(id);
+ if (cached) return cached;
+ const journalPath = getReadableJournalPath(id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ return rebuilt;
+ }
+
+ 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;
+ }
+
+ return {
+ rootDir,
+ legacyRootDir,
+ appendEvent(event) {
+ const normalized = normalizeEvent(event, sessionId);
+ const journalPath = getJournalPath(rootDir, normalized.id);
+ const snapshotPath = getSnapshotPath(rootDir, normalized.id);
+ const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
+ if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
+ fs.copyFileSync(legacyJournalPath, journalPath);
+ }
+ const prior = loadCachedOrRebuild(normalized.id);
+ const seq = prior.nextSeq;
+ const entry = {
+ seq,
+ 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, prior.diagnostics);
+ snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
+ writeSnapshot(snapshotPath, next);
+ return next;
+ },
+ getSnapshot(id = sessionId, opts = {}) {
+ if (!id) throw new Error('session id required');
+ const journalPath = getReadableJournalPath(id);
+ const snapshotPath = getSnapshotPath(rootDir, id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ writeSnapshot(snapshotPath, rebuilt.snapshot);
+ if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
+ return rebuilt.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));
+ }
+ }
+ return [...ids]
+ .sort()
+ .map((id) => this.getSnapshot(id))
+ .filter(Boolean);
+ },
+ };
+}
+
+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 safeSessionId(id) {
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
+ return id;
+}
+
+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,
+ activeOwner: null,
+ sourceMarkers: {},
+ fallbackMode: null,
+ annotationArtifacts: [],
+ diagnostics: [],
+ updatedAt: 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, inheritedDiagnostics = []) {
+ const event = entry.event || entry;
+ const next = {
+ ...snapshot,
+ paramValues: { ...(snapshot.paramValues || {}) },
+ sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
+ annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
+ diagnostics: [...(snapshot.diagnostics || [])],
+ updatedAt: entry.ts || new Date().toISOString(),
+ };
+
+ if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
+ next.diagnostics = [...inheritedDiagnostics];
+ }
+
+ 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);
+ if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
+ break;
+ case 'variants_ready':
+ case 'agent_done':
+ next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
+ 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.',
+ });
+ }
+ break;
+ case 'checkpoint':
+ if (COMPLETED_PHASES.has(next.phase)) {
+ next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
+ break;
+ }
+ if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
+ next.phase = event.phase ?? next.phase;
+ next.checkpointRevision = event.revision ?? next.checkpointRevision;
+ next.activeOwner = event.owner ?? next.activeOwner;
+ next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
+ next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
+ next.sourceFile = event.sourceFile ?? next.sourceFile;
+ next.previewFile = event.previewFile ?? next.previewFile;
+ next.previewMode = event.previewMode ?? next.previewMode;
+ if (event.paramValues) next.paramValues = { ...event.paramValues };
+ } else {
+ next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
+ }
+ break;
+ case 'accept':
+ case 'accept_intent':
+ next.phase = 'accept_requested';
+ 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 '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.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':
+ 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) {
+ fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
+}
diff --git a/.pi/skills/impeccable/scripts/live-svelte-component.mjs b/.pi/skills/impeccable/scripts/live/svelte-component.mjs
similarity index 100%
rename from .pi/skills/impeccable/scripts/live-svelte-component.mjs
rename to .pi/skills/impeccable/scripts/live/svelte-component.mjs
diff --git a/.pi/skills/impeccable/scripts/live-sveltekit-adapter.mjs b/.pi/skills/impeccable/scripts/live/sveltekit-adapter.mjs
similarity index 100%
rename from .pi/skills/impeccable/scripts/live-sveltekit-adapter.mjs
rename to .pi/skills/impeccable/scripts/live/sveltekit-adapter.mjs
diff --git a/.pi/skills/impeccable/scripts/live-ui-core.mjs b/.pi/skills/impeccable/scripts/live/ui-core.mjs
similarity index 100%
rename from .pi/skills/impeccable/scripts/live-ui-core.mjs
rename to .pi/skills/impeccable/scripts/live/ui-core.mjs
diff --git a/.pi/skills/impeccable/scripts/live/vocabulary.mjs b/.pi/skills/impeccable/scripts/live/vocabulary.mjs
new file mode 100644
index 000000000..5c7b0b713
--- /dev/null
+++ b/.pi/skills/impeccable/scripts/live/vocabulary.mjs
@@ -0,0 +1,36 @@
+/**
+ * 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: `` },
+ { value: 'bolder', label: 'Bolder', icon: `` },
+ { value: 'quieter', label: 'Quieter', icon: `` },
+ { value: 'distill', label: 'Distill', icon: `` },
+ { value: 'polish', label: 'Polish', icon: `` },
+ { value: 'typeset', label: 'Typeset', icon: `` },
+ { value: 'colorize', label: 'Colorize', icon: `` },
+ { value: 'layout', label: 'Layout', icon: `` },
+ { value: 'adapt', label: 'Adapt', icon: `` },
+ { value: 'animate', label: 'Animate', icon: `` },
+ { value: 'delight', label: 'Delight', icon: `` },
+ { value: 'overdrive', label: 'Overdrive', icon: `` },
+];
+
+// Action values accepted by the live event protocol, in palette order.
+export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.qoder/skills/impeccable/scripts/context-signals.mjs b/.qoder/skills/impeccable/scripts/context-signals.mjs
index c03843812..f631d7561 100644
--- a/.qoder/skills/impeccable/scripts/context-signals.mjs
+++ b/.qoder/skills/impeccable/scripts/context-signals.mjs
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
diff --git a/.qoder/skills/impeccable/scripts/critique-storage.mjs b/.qoder/skills/impeccable/scripts/critique-storage.mjs
index eba12383e..645628c8c 100644
--- a/.qoder/skills/impeccable/scripts/critique-storage.mjs
+++ b/.qoder/skills/impeccable/scripts/critique-storage.mjs
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
diff --git a/.qoder/skills/impeccable/scripts/design-parser.mjs b/.qoder/skills/impeccable/scripts/lib/design-parser.mjs
similarity index 100%
rename from .qoder/skills/impeccable/scripts/design-parser.mjs
rename to .qoder/skills/impeccable/scripts/lib/design-parser.mjs
diff --git a/.qoder/skills/impeccable/scripts/impeccable-paths.mjs b/.qoder/skills/impeccable/scripts/lib/impeccable-paths.mjs
similarity index 100%
rename from .qoder/skills/impeccable/scripts/impeccable-paths.mjs
rename to .qoder/skills/impeccable/scripts/lib/impeccable-paths.mjs
diff --git a/.qoder/skills/impeccable/scripts/is-generated.mjs b/.qoder/skills/impeccable/scripts/lib/is-generated.mjs
similarity index 100%
rename from .qoder/skills/impeccable/scripts/is-generated.mjs
rename to .qoder/skills/impeccable/scripts/lib/is-generated.mjs
diff --git a/.qoder/skills/impeccable/scripts/live-accept.mjs b/.qoder/skills/impeccable/scripts/live-accept.mjs
index 5e8687187..278dfbb2e 100644
--- a/.qoder/skills/impeccable/scripts/live-accept.mjs
+++ b/.qoder/skills/impeccable/scripts/live-accept.mjs
@@ -15,14 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.qoder/skills/impeccable/scripts/live-browser.js b/.qoder/skills/impeccable/scripts/live-browser.js
index 0a533945f..9c4b6c21e 100644
--- a/.qoder/skills/impeccable/scripts/live-browser.js
+++ b/.qoder/skills/impeccable/scripts/live-browser.js
@@ -82,7 +82,7 @@
]);
// Command vocabulary (values + labels + icons) comes from the canonical source,
- // skill/scripts/live-vocabulary.mjs, which live-server.mjs serializes into
+ // skill/scripts/live/vocabulary.mjs, which live-server.mjs serializes into
// window.__IMPECCABLE_VOCAB__ when it serves /live.js (same injection path as
// the token/port above, so it is always present here). The icons stack above
// each chip label and recolor to C.brand when selected (strokes use
@@ -1216,7 +1216,7 @@
: (focused ? BP.accentSoft : BP.hairline);
}
- // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs)
+ // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs)
function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
diff --git a/.qoder/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.qoder/skills/impeccable/scripts/live-commit-manual-edits.mjs
index 44bc5ea4b..8bde46b50 100644
--- a/.qoder/skills/impeccable/scripts/live-commit-manual-edits.mjs
+++ b/.qoder/skills/impeccable/scripts/live-commit-manual-edits.mjs
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
-import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
-import { isGeneratedFile } from './is-generated.mjs';
+import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
diff --git a/.qoder/skills/impeccable/scripts/live-complete.mjs b/.qoder/skills/impeccable/scripts/live-complete.mjs
index 78155af85..1cc4e7543 100644
--- a/.qoder/skills/impeccable/scripts/live-complete.mjs
+++ b/.qoder/skills/impeccable/scripts/live-complete.mjs
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
diff --git a/.qoder/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.qoder/skills/impeccable/scripts/live-discard-manual-edits.mjs
index 435f22413..aa669fd20 100644
--- a/.qoder/skills/impeccable/scripts/live-discard-manual-edits.mjs
+++ b/.qoder/skills/impeccable/scripts/live-discard-manual-edits.mjs
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
-import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
+import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
diff --git a/.qoder/skills/impeccable/scripts/live-event-validation.mjs b/.qoder/skills/impeccable/scripts/live-event-validation.mjs
deleted file mode 100644
index 15ae3265d..000000000
--- a/.qoder/skills/impeccable/scripts/live-event-validation.mjs
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * Shared event validation for the live helper server.
- * Extracted for unit testing (insert mode rules).
- */
-
-import { canCreateInsert } from './live-insert-ui.mjs';
-
-// The accepted visual action values come from the canonical vocabulary so the
-// validator, the picker UI, and the marketing demo never drift. Imported (not
-// just re-exported) so it is also in scope for the validators below.
-import { VISUAL_ACTIONS } from './live-vocabulary.mjs';
-export { VISUAL_ACTIONS };
-
-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 = ['<', '{', '}', '`'];
-
-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;
-}
-
-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 '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;
- default:
- return 'Unknown event type: ' + msg.type;
- }
-}
diff --git a/.qoder/skills/impeccable/scripts/live-inject.mjs b/.qoder/skills/impeccable/scripts/live-inject.mjs
index 3a1f36e46..c006ad09e 100644
--- a/.qoder/skills/impeccable/scripts/live-inject.mjs
+++ b/.qoder/skills/impeccable/scripts/live-inject.mjs
@@ -16,12 +16,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { resolveLiveConfigPath } from './impeccable-paths.mjs';
+import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
-} from './live-sveltekit-adapter.mjs';
+} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
diff --git a/.qoder/skills/impeccable/scripts/live-insert.mjs b/.qoder/skills/impeccable/scripts/live-insert.mjs
index 0658e9914..0ed3cafea 100644
--- a/.qoder/skills/impeccable/scripts/live-insert.mjs
+++ b/.qoder/skills/impeccable/scripts/live-insert.mjs
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -25,7 +25,7 @@ import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
diff --git a/.qoder/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.qoder/skills/impeccable/scripts/live-manual-edit-evidence.mjs
index 860278b73..dd10e96dc 100644
--- a/.qoder/skills/impeccable/scripts/live-manual-edit-evidence.mjs
+++ b/.qoder/skills/impeccable/scripts/live-manual-edit-evidence.mjs
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
diff --git a/.qoder/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.qoder/skills/impeccable/scripts/live-manual-edits-buffer.mjs
deleted file mode 100644
index 9e3dcf455..000000000
--- a/.qoder/skills/impeccable/scripts/live-manual-edits-buffer.mjs
+++ /dev/null
@@ -1,152 +0,0 @@
-/**
- * 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 './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;
-}
diff --git a/.qoder/skills/impeccable/scripts/live-poll.mjs b/.qoder/skills/impeccable/scripts/live-poll.mjs
index 1e1259fbc..b031055fe 100644
--- a/.qoder/skills/impeccable/scripts/live-poll.mjs
+++ b/.qoder/skills/impeccable/scripts/live-poll.mjs
@@ -12,8 +12,8 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
diff --git a/.qoder/skills/impeccable/scripts/live-resume.mjs b/.qoder/skills/impeccable/scripts/live-resume.mjs
index e54831f12..74284d48a 100644
--- a/.qoder/skills/impeccable/scripts/live-resume.mjs
+++ b/.qoder/skills/impeccable/scripts/live-resume.mjs
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
diff --git a/.qoder/skills/impeccable/scripts/live-server.mjs b/.qoder/skills/impeccable/scripts/live-server.mjs
index d9e06f449..187af0ac1 100644
--- a/.qoder/skills/impeccable/scripts/live-server.mjs
+++ b/.qoder/skills/impeccable/scripts/live-server.mjs
@@ -20,11 +20,11 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
-import { parseDesignMd } from './design-parser.mjs';
+import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { validateEvent } from './live-event-validation.mjs';
-import { LIVE_COMMANDS } from './live-vocabulary.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { validateEvent } from './live/event-validation.mjs';
+import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -33,20 +33,20 @@ import {
removeLiveServerInfo,
resolveDesignSidecarPath,
writeLiveServerInfo,
-} from './impeccable-paths.mjs';
+} from './lib/impeccable-paths.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
-} from './live-manual-edits-buffer.mjs';
+} from './live/manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
diff --git a/.qoder/skills/impeccable/scripts/live-session-store.mjs b/.qoder/skills/impeccable/scripts/live-session-store.mjs
deleted file mode 100644
index 5ec4d34d4..000000000
--- a/.qoder/skills/impeccable/scripts/live-session-store.mjs
+++ /dev/null
@@ -1,289 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
-
-const COMPLETED_PHASES = new Set(['completed', 'discarded']);
-
-export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
- const rootDir = getLiveSessionsDir(cwd);
- const legacyRootDir = getLegacyLiveSessionsDir(cwd);
- fs.mkdirSync(rootDir, { recursive: true });
- const snapshotCache = new Map();
-
- function loadCachedOrRebuild(id) {
- const cached = snapshotCache.get(id);
- if (cached) return cached;
- const journalPath = getReadableJournalPath(id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- return rebuilt;
- }
-
- 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;
- }
-
- return {
- rootDir,
- legacyRootDir,
- appendEvent(event) {
- const normalized = normalizeEvent(event, sessionId);
- const journalPath = getJournalPath(rootDir, normalized.id);
- const snapshotPath = getSnapshotPath(rootDir, normalized.id);
- const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
- if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
- fs.copyFileSync(legacyJournalPath, journalPath);
- }
- const prior = loadCachedOrRebuild(normalized.id);
- const seq = prior.nextSeq;
- const entry = {
- seq,
- 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, prior.diagnostics);
- snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
- writeSnapshot(snapshotPath, next);
- return next;
- },
- getSnapshot(id = sessionId, opts = {}) {
- if (!id) throw new Error('session id required');
- const journalPath = getReadableJournalPath(id);
- const snapshotPath = getSnapshotPath(rootDir, id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- writeSnapshot(snapshotPath, rebuilt.snapshot);
- if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
- return rebuilt.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));
- }
- }
- return [...ids]
- .sort()
- .map((id) => this.getSnapshot(id))
- .filter(Boolean);
- },
- };
-}
-
-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 safeSessionId(id) {
- if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
- return id;
-}
-
-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,
- activeOwner: null,
- sourceMarkers: {},
- fallbackMode: null,
- annotationArtifacts: [],
- diagnostics: [],
- updatedAt: 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, inheritedDiagnostics = []) {
- const event = entry.event || entry;
- const next = {
- ...snapshot,
- paramValues: { ...(snapshot.paramValues || {}) },
- sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
- annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
- diagnostics: [...(snapshot.diagnostics || [])],
- updatedAt: entry.ts || new Date().toISOString(),
- };
-
- if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
- next.diagnostics = [...inheritedDiagnostics];
- }
-
- 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);
- if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
- break;
- case 'variants_ready':
- case 'agent_done':
- next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
- 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.',
- });
- }
- break;
- case 'checkpoint':
- if (COMPLETED_PHASES.has(next.phase)) {
- next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
- break;
- }
- if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
- next.phase = event.phase ?? next.phase;
- next.checkpointRevision = event.revision ?? next.checkpointRevision;
- next.activeOwner = event.owner ?? next.activeOwner;
- next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
- next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
- next.sourceFile = event.sourceFile ?? next.sourceFile;
- next.previewFile = event.previewFile ?? next.previewFile;
- next.previewMode = event.previewMode ?? next.previewMode;
- if (event.paramValues) next.paramValues = { ...event.paramValues };
- } else {
- next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
- }
- break;
- case 'accept':
- case 'accept_intent':
- next.phase = 'accept_requested';
- 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 '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.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':
- 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) {
- fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
-}
diff --git a/.qoder/skills/impeccable/scripts/live-status.mjs b/.qoder/skills/impeccable/scripts/live-status.mjs
index a7009cc29..f7e464999 100644
--- a/.qoder/skills/impeccable/scripts/live-status.mjs
+++ b/.qoder/skills/impeccable/scripts/live-status.mjs
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
diff --git a/.qoder/skills/impeccable/scripts/live-vocabulary.mjs b/.qoder/skills/impeccable/scripts/live-vocabulary.mjs
deleted file mode 100644
index 1123d41c4..000000000
--- a/.qoder/skills/impeccable/scripts/live-vocabulary.mjs
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * 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: `` },
- { value: 'bolder', label: 'Bolder', icon: `` },
- { value: 'quieter', label: 'Quieter', icon: `` },
- { value: 'distill', label: 'Distill', icon: `` },
- { value: 'polish', label: 'Polish', icon: `` },
- { value: 'typeset', label: 'Typeset', icon: `` },
- { value: 'colorize', label: 'Colorize', icon: `` },
- { value: 'layout', label: 'Layout', icon: `` },
- { value: 'adapt', label: 'Adapt', icon: `` },
- { value: 'animate', label: 'Animate', icon: `` },
- { value: 'delight', label: 'Delight', icon: `` },
- { value: 'overdrive', label: 'Overdrive', icon: `` },
-];
-
-// Action values accepted by the live event protocol, in palette order.
-export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.qoder/skills/impeccable/scripts/live-wrap.mjs b/.qoder/skills/impeccable/scripts/live-wrap.mjs
index a83cbcc56..c3f00f3a4 100644
--- a/.qoder/skills/impeccable/scripts/live-wrap.mjs
+++ b/.qoder/skills/impeccable/scripts/live-wrap.mjs
@@ -13,13 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.qoder/skills/impeccable/scripts/live.mjs b/.qoder/skills/impeccable/scripts/live.mjs
index 8acd300ed..0992da1bd 100644
--- a/.qoder/skills/impeccable/scripts/live.mjs
+++ b/.qoder/skills/impeccable/scripts/live.mjs
@@ -23,7 +23,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
diff --git a/.qoder/skills/impeccable/scripts/live-completion.mjs b/.qoder/skills/impeccable/scripts/live/completion.mjs
similarity index 100%
rename from .qoder/skills/impeccable/scripts/live-completion.mjs
rename to .qoder/skills/impeccable/scripts/live/completion.mjs
diff --git a/.qoder/skills/impeccable/scripts/live/event-validation.mjs b/.qoder/skills/impeccable/scripts/live/event-validation.mjs
new file mode 100644
index 000000000..0897b1317
--- /dev/null
+++ b/.qoder/skills/impeccable/scripts/live/event-validation.mjs
@@ -0,0 +1,137 @@
+/**
+ * Shared event validation for the live helper server.
+ * Extracted for unit testing (insert mode rules).
+ */
+
+import { canCreateInsert } from './insert-ui.mjs';
+
+// The accepted visual action values come from the canonical vocabulary so the
+// validator, the picker UI, and the marketing demo never drift. Imported (not
+// just re-exported) so it is also in scope for the validators below.
+import { VISUAL_ACTIONS } from './vocabulary.mjs';
+export { VISUAL_ACTIONS };
+
+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 = ['<', '{', '}', '`'];
+
+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;
+}
+
+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 '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;
+ default:
+ return 'Unknown event type: ' + msg.type;
+ }
+}
diff --git a/.qoder/skills/impeccable/scripts/live-insert-ui.mjs b/.qoder/skills/impeccable/scripts/live/insert-ui.mjs
similarity index 100%
rename from .qoder/skills/impeccable/scripts/live-insert-ui.mjs
rename to .qoder/skills/impeccable/scripts/live/insert-ui.mjs
diff --git a/.qoder/skills/impeccable/scripts/live/manual-edits-buffer.mjs b/.qoder/skills/impeccable/scripts/live/manual-edits-buffer.mjs
new file mode 100644
index 000000000..d96ebbe34
--- /dev/null
+++ b/.qoder/skills/impeccable/scripts/live/manual-edits-buffer.mjs
@@ -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;
+}
diff --git a/.qoder/skills/impeccable/scripts/live/session-store.mjs b/.qoder/skills/impeccable/scripts/live/session-store.mjs
new file mode 100644
index 000000000..affba67c9
--- /dev/null
+++ b/.qoder/skills/impeccable/scripts/live/session-store.mjs
@@ -0,0 +1,289 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
+
+const COMPLETED_PHASES = new Set(['completed', 'discarded']);
+
+export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
+ const rootDir = getLiveSessionsDir(cwd);
+ const legacyRootDir = getLegacyLiveSessionsDir(cwd);
+ fs.mkdirSync(rootDir, { recursive: true });
+ const snapshotCache = new Map();
+
+ function loadCachedOrRebuild(id) {
+ const cached = snapshotCache.get(id);
+ if (cached) return cached;
+ const journalPath = getReadableJournalPath(id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ return rebuilt;
+ }
+
+ 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;
+ }
+
+ return {
+ rootDir,
+ legacyRootDir,
+ appendEvent(event) {
+ const normalized = normalizeEvent(event, sessionId);
+ const journalPath = getJournalPath(rootDir, normalized.id);
+ const snapshotPath = getSnapshotPath(rootDir, normalized.id);
+ const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
+ if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
+ fs.copyFileSync(legacyJournalPath, journalPath);
+ }
+ const prior = loadCachedOrRebuild(normalized.id);
+ const seq = prior.nextSeq;
+ const entry = {
+ seq,
+ 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, prior.diagnostics);
+ snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
+ writeSnapshot(snapshotPath, next);
+ return next;
+ },
+ getSnapshot(id = sessionId, opts = {}) {
+ if (!id) throw new Error('session id required');
+ const journalPath = getReadableJournalPath(id);
+ const snapshotPath = getSnapshotPath(rootDir, id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ writeSnapshot(snapshotPath, rebuilt.snapshot);
+ if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
+ return rebuilt.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));
+ }
+ }
+ return [...ids]
+ .sort()
+ .map((id) => this.getSnapshot(id))
+ .filter(Boolean);
+ },
+ };
+}
+
+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 safeSessionId(id) {
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
+ return id;
+}
+
+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,
+ activeOwner: null,
+ sourceMarkers: {},
+ fallbackMode: null,
+ annotationArtifacts: [],
+ diagnostics: [],
+ updatedAt: 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, inheritedDiagnostics = []) {
+ const event = entry.event || entry;
+ const next = {
+ ...snapshot,
+ paramValues: { ...(snapshot.paramValues || {}) },
+ sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
+ annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
+ diagnostics: [...(snapshot.diagnostics || [])],
+ updatedAt: entry.ts || new Date().toISOString(),
+ };
+
+ if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
+ next.diagnostics = [...inheritedDiagnostics];
+ }
+
+ 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);
+ if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
+ break;
+ case 'variants_ready':
+ case 'agent_done':
+ next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
+ 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.',
+ });
+ }
+ break;
+ case 'checkpoint':
+ if (COMPLETED_PHASES.has(next.phase)) {
+ next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
+ break;
+ }
+ if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
+ next.phase = event.phase ?? next.phase;
+ next.checkpointRevision = event.revision ?? next.checkpointRevision;
+ next.activeOwner = event.owner ?? next.activeOwner;
+ next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
+ next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
+ next.sourceFile = event.sourceFile ?? next.sourceFile;
+ next.previewFile = event.previewFile ?? next.previewFile;
+ next.previewMode = event.previewMode ?? next.previewMode;
+ if (event.paramValues) next.paramValues = { ...event.paramValues };
+ } else {
+ next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
+ }
+ break;
+ case 'accept':
+ case 'accept_intent':
+ next.phase = 'accept_requested';
+ 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 '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.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':
+ 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) {
+ fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
+}
diff --git a/.qoder/skills/impeccable/scripts/live-svelte-component.mjs b/.qoder/skills/impeccable/scripts/live/svelte-component.mjs
similarity index 100%
rename from .qoder/skills/impeccable/scripts/live-svelte-component.mjs
rename to .qoder/skills/impeccable/scripts/live/svelte-component.mjs
diff --git a/.qoder/skills/impeccable/scripts/live-sveltekit-adapter.mjs b/.qoder/skills/impeccable/scripts/live/sveltekit-adapter.mjs
similarity index 100%
rename from .qoder/skills/impeccable/scripts/live-sveltekit-adapter.mjs
rename to .qoder/skills/impeccable/scripts/live/sveltekit-adapter.mjs
diff --git a/.qoder/skills/impeccable/scripts/live-ui-core.mjs b/.qoder/skills/impeccable/scripts/live/ui-core.mjs
similarity index 100%
rename from .qoder/skills/impeccable/scripts/live-ui-core.mjs
rename to .qoder/skills/impeccable/scripts/live/ui-core.mjs
diff --git a/.qoder/skills/impeccable/scripts/live/vocabulary.mjs b/.qoder/skills/impeccable/scripts/live/vocabulary.mjs
new file mode 100644
index 000000000..5c7b0b713
--- /dev/null
+++ b/.qoder/skills/impeccable/scripts/live/vocabulary.mjs
@@ -0,0 +1,36 @@
+/**
+ * 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: `` },
+ { value: 'bolder', label: 'Bolder', icon: `` },
+ { value: 'quieter', label: 'Quieter', icon: `` },
+ { value: 'distill', label: 'Distill', icon: `` },
+ { value: 'polish', label: 'Polish', icon: `` },
+ { value: 'typeset', label: 'Typeset', icon: `` },
+ { value: 'colorize', label: 'Colorize', icon: `` },
+ { value: 'layout', label: 'Layout', icon: `` },
+ { value: 'adapt', label: 'Adapt', icon: `` },
+ { value: 'animate', label: 'Animate', icon: `` },
+ { value: 'delight', label: 'Delight', icon: `` },
+ { value: 'overdrive', label: 'Overdrive', icon: `` },
+];
+
+// Action values accepted by the live event protocol, in palette order.
+export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.rovodev/skills/impeccable/scripts/context-signals.mjs b/.rovodev/skills/impeccable/scripts/context-signals.mjs
index c03843812..f631d7561 100644
--- a/.rovodev/skills/impeccable/scripts/context-signals.mjs
+++ b/.rovodev/skills/impeccable/scripts/context-signals.mjs
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
diff --git a/.rovodev/skills/impeccable/scripts/critique-storage.mjs b/.rovodev/skills/impeccable/scripts/critique-storage.mjs
index eba12383e..645628c8c 100644
--- a/.rovodev/skills/impeccable/scripts/critique-storage.mjs
+++ b/.rovodev/skills/impeccable/scripts/critique-storage.mjs
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
diff --git a/.rovodev/skills/impeccable/scripts/design-parser.mjs b/.rovodev/skills/impeccable/scripts/lib/design-parser.mjs
similarity index 100%
rename from .rovodev/skills/impeccable/scripts/design-parser.mjs
rename to .rovodev/skills/impeccable/scripts/lib/design-parser.mjs
diff --git a/.rovodev/skills/impeccable/scripts/impeccable-paths.mjs b/.rovodev/skills/impeccable/scripts/lib/impeccable-paths.mjs
similarity index 100%
rename from .rovodev/skills/impeccable/scripts/impeccable-paths.mjs
rename to .rovodev/skills/impeccable/scripts/lib/impeccable-paths.mjs
diff --git a/.rovodev/skills/impeccable/scripts/is-generated.mjs b/.rovodev/skills/impeccable/scripts/lib/is-generated.mjs
similarity index 100%
rename from .rovodev/skills/impeccable/scripts/is-generated.mjs
rename to .rovodev/skills/impeccable/scripts/lib/is-generated.mjs
diff --git a/.rovodev/skills/impeccable/scripts/live-accept.mjs b/.rovodev/skills/impeccable/scripts/live-accept.mjs
index 5e8687187..278dfbb2e 100644
--- a/.rovodev/skills/impeccable/scripts/live-accept.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-accept.mjs
@@ -15,14 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.rovodev/skills/impeccable/scripts/live-browser.js b/.rovodev/skills/impeccable/scripts/live-browser.js
index 0a533945f..9c4b6c21e 100644
--- a/.rovodev/skills/impeccable/scripts/live-browser.js
+++ b/.rovodev/skills/impeccable/scripts/live-browser.js
@@ -82,7 +82,7 @@
]);
// Command vocabulary (values + labels + icons) comes from the canonical source,
- // skill/scripts/live-vocabulary.mjs, which live-server.mjs serializes into
+ // skill/scripts/live/vocabulary.mjs, which live-server.mjs serializes into
// window.__IMPECCABLE_VOCAB__ when it serves /live.js (same injection path as
// the token/port above, so it is always present here). The icons stack above
// each chip label and recolor to C.brand when selected (strokes use
@@ -1216,7 +1216,7 @@
: (focused ? BP.accentSoft : BP.hairline);
}
- // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs)
+ // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs)
function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
diff --git a/.rovodev/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.rovodev/skills/impeccable/scripts/live-commit-manual-edits.mjs
index 44bc5ea4b..8bde46b50 100644
--- a/.rovodev/skills/impeccable/scripts/live-commit-manual-edits.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-commit-manual-edits.mjs
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
-import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
-import { isGeneratedFile } from './is-generated.mjs';
+import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
diff --git a/.rovodev/skills/impeccable/scripts/live-complete.mjs b/.rovodev/skills/impeccable/scripts/live-complete.mjs
index 78155af85..1cc4e7543 100644
--- a/.rovodev/skills/impeccable/scripts/live-complete.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-complete.mjs
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
diff --git a/.rovodev/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.rovodev/skills/impeccable/scripts/live-discard-manual-edits.mjs
index 435f22413..aa669fd20 100644
--- a/.rovodev/skills/impeccable/scripts/live-discard-manual-edits.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-discard-manual-edits.mjs
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
-import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
+import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
diff --git a/.rovodev/skills/impeccable/scripts/live-event-validation.mjs b/.rovodev/skills/impeccable/scripts/live-event-validation.mjs
deleted file mode 100644
index 15ae3265d..000000000
--- a/.rovodev/skills/impeccable/scripts/live-event-validation.mjs
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * Shared event validation for the live helper server.
- * Extracted for unit testing (insert mode rules).
- */
-
-import { canCreateInsert } from './live-insert-ui.mjs';
-
-// The accepted visual action values come from the canonical vocabulary so the
-// validator, the picker UI, and the marketing demo never drift. Imported (not
-// just re-exported) so it is also in scope for the validators below.
-import { VISUAL_ACTIONS } from './live-vocabulary.mjs';
-export { VISUAL_ACTIONS };
-
-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 = ['<', '{', '}', '`'];
-
-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;
-}
-
-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 '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;
- default:
- return 'Unknown event type: ' + msg.type;
- }
-}
diff --git a/.rovodev/skills/impeccable/scripts/live-inject.mjs b/.rovodev/skills/impeccable/scripts/live-inject.mjs
index 3a1f36e46..c006ad09e 100644
--- a/.rovodev/skills/impeccable/scripts/live-inject.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-inject.mjs
@@ -16,12 +16,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { resolveLiveConfigPath } from './impeccable-paths.mjs';
+import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
-} from './live-sveltekit-adapter.mjs';
+} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
diff --git a/.rovodev/skills/impeccable/scripts/live-insert.mjs b/.rovodev/skills/impeccable/scripts/live-insert.mjs
index 0658e9914..0ed3cafea 100644
--- a/.rovodev/skills/impeccable/scripts/live-insert.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-insert.mjs
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -25,7 +25,7 @@ import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
diff --git a/.rovodev/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.rovodev/skills/impeccable/scripts/live-manual-edit-evidence.mjs
index 860278b73..dd10e96dc 100644
--- a/.rovodev/skills/impeccable/scripts/live-manual-edit-evidence.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-manual-edit-evidence.mjs
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
diff --git a/.rovodev/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.rovodev/skills/impeccable/scripts/live-manual-edits-buffer.mjs
deleted file mode 100644
index 9e3dcf455..000000000
--- a/.rovodev/skills/impeccable/scripts/live-manual-edits-buffer.mjs
+++ /dev/null
@@ -1,152 +0,0 @@
-/**
- * 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 './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;
-}
diff --git a/.rovodev/skills/impeccable/scripts/live-poll.mjs b/.rovodev/skills/impeccable/scripts/live-poll.mjs
index 1e1259fbc..b031055fe 100644
--- a/.rovodev/skills/impeccable/scripts/live-poll.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-poll.mjs
@@ -12,8 +12,8 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
diff --git a/.rovodev/skills/impeccable/scripts/live-resume.mjs b/.rovodev/skills/impeccable/scripts/live-resume.mjs
index e54831f12..74284d48a 100644
--- a/.rovodev/skills/impeccable/scripts/live-resume.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-resume.mjs
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs
index d9e06f449..187af0ac1 100644
--- a/.rovodev/skills/impeccable/scripts/live-server.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-server.mjs
@@ -20,11 +20,11 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
-import { parseDesignMd } from './design-parser.mjs';
+import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { validateEvent } from './live-event-validation.mjs';
-import { LIVE_COMMANDS } from './live-vocabulary.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { validateEvent } from './live/event-validation.mjs';
+import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -33,20 +33,20 @@ import {
removeLiveServerInfo,
resolveDesignSidecarPath,
writeLiveServerInfo,
-} from './impeccable-paths.mjs';
+} from './lib/impeccable-paths.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
-} from './live-manual-edits-buffer.mjs';
+} from './live/manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
diff --git a/.rovodev/skills/impeccable/scripts/live-session-store.mjs b/.rovodev/skills/impeccable/scripts/live-session-store.mjs
deleted file mode 100644
index 5ec4d34d4..000000000
--- a/.rovodev/skills/impeccable/scripts/live-session-store.mjs
+++ /dev/null
@@ -1,289 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
-
-const COMPLETED_PHASES = new Set(['completed', 'discarded']);
-
-export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
- const rootDir = getLiveSessionsDir(cwd);
- const legacyRootDir = getLegacyLiveSessionsDir(cwd);
- fs.mkdirSync(rootDir, { recursive: true });
- const snapshotCache = new Map();
-
- function loadCachedOrRebuild(id) {
- const cached = snapshotCache.get(id);
- if (cached) return cached;
- const journalPath = getReadableJournalPath(id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- return rebuilt;
- }
-
- 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;
- }
-
- return {
- rootDir,
- legacyRootDir,
- appendEvent(event) {
- const normalized = normalizeEvent(event, sessionId);
- const journalPath = getJournalPath(rootDir, normalized.id);
- const snapshotPath = getSnapshotPath(rootDir, normalized.id);
- const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
- if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
- fs.copyFileSync(legacyJournalPath, journalPath);
- }
- const prior = loadCachedOrRebuild(normalized.id);
- const seq = prior.nextSeq;
- const entry = {
- seq,
- 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, prior.diagnostics);
- snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
- writeSnapshot(snapshotPath, next);
- return next;
- },
- getSnapshot(id = sessionId, opts = {}) {
- if (!id) throw new Error('session id required');
- const journalPath = getReadableJournalPath(id);
- const snapshotPath = getSnapshotPath(rootDir, id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- writeSnapshot(snapshotPath, rebuilt.snapshot);
- if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
- return rebuilt.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));
- }
- }
- return [...ids]
- .sort()
- .map((id) => this.getSnapshot(id))
- .filter(Boolean);
- },
- };
-}
-
-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 safeSessionId(id) {
- if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
- return id;
-}
-
-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,
- activeOwner: null,
- sourceMarkers: {},
- fallbackMode: null,
- annotationArtifacts: [],
- diagnostics: [],
- updatedAt: 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, inheritedDiagnostics = []) {
- const event = entry.event || entry;
- const next = {
- ...snapshot,
- paramValues: { ...(snapshot.paramValues || {}) },
- sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
- annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
- diagnostics: [...(snapshot.diagnostics || [])],
- updatedAt: entry.ts || new Date().toISOString(),
- };
-
- if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
- next.diagnostics = [...inheritedDiagnostics];
- }
-
- 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);
- if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
- break;
- case 'variants_ready':
- case 'agent_done':
- next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
- 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.',
- });
- }
- break;
- case 'checkpoint':
- if (COMPLETED_PHASES.has(next.phase)) {
- next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
- break;
- }
- if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
- next.phase = event.phase ?? next.phase;
- next.checkpointRevision = event.revision ?? next.checkpointRevision;
- next.activeOwner = event.owner ?? next.activeOwner;
- next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
- next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
- next.sourceFile = event.sourceFile ?? next.sourceFile;
- next.previewFile = event.previewFile ?? next.previewFile;
- next.previewMode = event.previewMode ?? next.previewMode;
- if (event.paramValues) next.paramValues = { ...event.paramValues };
- } else {
- next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
- }
- break;
- case 'accept':
- case 'accept_intent':
- next.phase = 'accept_requested';
- 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 '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.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':
- 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) {
- fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
-}
diff --git a/.rovodev/skills/impeccable/scripts/live-status.mjs b/.rovodev/skills/impeccable/scripts/live-status.mjs
index a7009cc29..f7e464999 100644
--- a/.rovodev/skills/impeccable/scripts/live-status.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-status.mjs
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
diff --git a/.rovodev/skills/impeccable/scripts/live-vocabulary.mjs b/.rovodev/skills/impeccable/scripts/live-vocabulary.mjs
deleted file mode 100644
index 1123d41c4..000000000
--- a/.rovodev/skills/impeccable/scripts/live-vocabulary.mjs
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * 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: `` },
- { value: 'bolder', label: 'Bolder', icon: `` },
- { value: 'quieter', label: 'Quieter', icon: `` },
- { value: 'distill', label: 'Distill', icon: `` },
- { value: 'polish', label: 'Polish', icon: `` },
- { value: 'typeset', label: 'Typeset', icon: `` },
- { value: 'colorize', label: 'Colorize', icon: `` },
- { value: 'layout', label: 'Layout', icon: `` },
- { value: 'adapt', label: 'Adapt', icon: `` },
- { value: 'animate', label: 'Animate', icon: `` },
- { value: 'delight', label: 'Delight', icon: `` },
- { value: 'overdrive', label: 'Overdrive', icon: `` },
-];
-
-// Action values accepted by the live event protocol, in palette order.
-export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.rovodev/skills/impeccable/scripts/live-wrap.mjs b/.rovodev/skills/impeccable/scripts/live-wrap.mjs
index a83cbcc56..c3f00f3a4 100644
--- a/.rovodev/skills/impeccable/scripts/live-wrap.mjs
+++ b/.rovodev/skills/impeccable/scripts/live-wrap.mjs
@@ -13,13 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.rovodev/skills/impeccable/scripts/live.mjs b/.rovodev/skills/impeccable/scripts/live.mjs
index 8acd300ed..0992da1bd 100644
--- a/.rovodev/skills/impeccable/scripts/live.mjs
+++ b/.rovodev/skills/impeccable/scripts/live.mjs
@@ -23,7 +23,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
diff --git a/.rovodev/skills/impeccable/scripts/live-completion.mjs b/.rovodev/skills/impeccable/scripts/live/completion.mjs
similarity index 100%
rename from .rovodev/skills/impeccable/scripts/live-completion.mjs
rename to .rovodev/skills/impeccable/scripts/live/completion.mjs
diff --git a/.rovodev/skills/impeccable/scripts/live/event-validation.mjs b/.rovodev/skills/impeccable/scripts/live/event-validation.mjs
new file mode 100644
index 000000000..0897b1317
--- /dev/null
+++ b/.rovodev/skills/impeccable/scripts/live/event-validation.mjs
@@ -0,0 +1,137 @@
+/**
+ * Shared event validation for the live helper server.
+ * Extracted for unit testing (insert mode rules).
+ */
+
+import { canCreateInsert } from './insert-ui.mjs';
+
+// The accepted visual action values come from the canonical vocabulary so the
+// validator, the picker UI, and the marketing demo never drift. Imported (not
+// just re-exported) so it is also in scope for the validators below.
+import { VISUAL_ACTIONS } from './vocabulary.mjs';
+export { VISUAL_ACTIONS };
+
+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 = ['<', '{', '}', '`'];
+
+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;
+}
+
+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 '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;
+ default:
+ return 'Unknown event type: ' + msg.type;
+ }
+}
diff --git a/.rovodev/skills/impeccable/scripts/live-insert-ui.mjs b/.rovodev/skills/impeccable/scripts/live/insert-ui.mjs
similarity index 100%
rename from .rovodev/skills/impeccable/scripts/live-insert-ui.mjs
rename to .rovodev/skills/impeccable/scripts/live/insert-ui.mjs
diff --git a/.rovodev/skills/impeccable/scripts/live/manual-edits-buffer.mjs b/.rovodev/skills/impeccable/scripts/live/manual-edits-buffer.mjs
new file mode 100644
index 000000000..d96ebbe34
--- /dev/null
+++ b/.rovodev/skills/impeccable/scripts/live/manual-edits-buffer.mjs
@@ -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;
+}
diff --git a/.rovodev/skills/impeccable/scripts/live/session-store.mjs b/.rovodev/skills/impeccable/scripts/live/session-store.mjs
new file mode 100644
index 000000000..affba67c9
--- /dev/null
+++ b/.rovodev/skills/impeccable/scripts/live/session-store.mjs
@@ -0,0 +1,289 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
+
+const COMPLETED_PHASES = new Set(['completed', 'discarded']);
+
+export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
+ const rootDir = getLiveSessionsDir(cwd);
+ const legacyRootDir = getLegacyLiveSessionsDir(cwd);
+ fs.mkdirSync(rootDir, { recursive: true });
+ const snapshotCache = new Map();
+
+ function loadCachedOrRebuild(id) {
+ const cached = snapshotCache.get(id);
+ if (cached) return cached;
+ const journalPath = getReadableJournalPath(id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ return rebuilt;
+ }
+
+ 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;
+ }
+
+ return {
+ rootDir,
+ legacyRootDir,
+ appendEvent(event) {
+ const normalized = normalizeEvent(event, sessionId);
+ const journalPath = getJournalPath(rootDir, normalized.id);
+ const snapshotPath = getSnapshotPath(rootDir, normalized.id);
+ const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
+ if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
+ fs.copyFileSync(legacyJournalPath, journalPath);
+ }
+ const prior = loadCachedOrRebuild(normalized.id);
+ const seq = prior.nextSeq;
+ const entry = {
+ seq,
+ 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, prior.diagnostics);
+ snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
+ writeSnapshot(snapshotPath, next);
+ return next;
+ },
+ getSnapshot(id = sessionId, opts = {}) {
+ if (!id) throw new Error('session id required');
+ const journalPath = getReadableJournalPath(id);
+ const snapshotPath = getSnapshotPath(rootDir, id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ writeSnapshot(snapshotPath, rebuilt.snapshot);
+ if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
+ return rebuilt.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));
+ }
+ }
+ return [...ids]
+ .sort()
+ .map((id) => this.getSnapshot(id))
+ .filter(Boolean);
+ },
+ };
+}
+
+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 safeSessionId(id) {
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
+ return id;
+}
+
+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,
+ activeOwner: null,
+ sourceMarkers: {},
+ fallbackMode: null,
+ annotationArtifacts: [],
+ diagnostics: [],
+ updatedAt: 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, inheritedDiagnostics = []) {
+ const event = entry.event || entry;
+ const next = {
+ ...snapshot,
+ paramValues: { ...(snapshot.paramValues || {}) },
+ sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
+ annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
+ diagnostics: [...(snapshot.diagnostics || [])],
+ updatedAt: entry.ts || new Date().toISOString(),
+ };
+
+ if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
+ next.diagnostics = [...inheritedDiagnostics];
+ }
+
+ 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);
+ if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
+ break;
+ case 'variants_ready':
+ case 'agent_done':
+ next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
+ 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.',
+ });
+ }
+ break;
+ case 'checkpoint':
+ if (COMPLETED_PHASES.has(next.phase)) {
+ next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
+ break;
+ }
+ if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
+ next.phase = event.phase ?? next.phase;
+ next.checkpointRevision = event.revision ?? next.checkpointRevision;
+ next.activeOwner = event.owner ?? next.activeOwner;
+ next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
+ next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
+ next.sourceFile = event.sourceFile ?? next.sourceFile;
+ next.previewFile = event.previewFile ?? next.previewFile;
+ next.previewMode = event.previewMode ?? next.previewMode;
+ if (event.paramValues) next.paramValues = { ...event.paramValues };
+ } else {
+ next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
+ }
+ break;
+ case 'accept':
+ case 'accept_intent':
+ next.phase = 'accept_requested';
+ 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 '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.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':
+ 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) {
+ fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
+}
diff --git a/.rovodev/skills/impeccable/scripts/live-svelte-component.mjs b/.rovodev/skills/impeccable/scripts/live/svelte-component.mjs
similarity index 100%
rename from .rovodev/skills/impeccable/scripts/live-svelte-component.mjs
rename to .rovodev/skills/impeccable/scripts/live/svelte-component.mjs
diff --git a/.rovodev/skills/impeccable/scripts/live-sveltekit-adapter.mjs b/.rovodev/skills/impeccable/scripts/live/sveltekit-adapter.mjs
similarity index 100%
rename from .rovodev/skills/impeccable/scripts/live-sveltekit-adapter.mjs
rename to .rovodev/skills/impeccable/scripts/live/sveltekit-adapter.mjs
diff --git a/.rovodev/skills/impeccable/scripts/live-ui-core.mjs b/.rovodev/skills/impeccable/scripts/live/ui-core.mjs
similarity index 100%
rename from .rovodev/skills/impeccable/scripts/live-ui-core.mjs
rename to .rovodev/skills/impeccable/scripts/live/ui-core.mjs
diff --git a/.rovodev/skills/impeccable/scripts/live/vocabulary.mjs b/.rovodev/skills/impeccable/scripts/live/vocabulary.mjs
new file mode 100644
index 000000000..5c7b0b713
--- /dev/null
+++ b/.rovodev/skills/impeccable/scripts/live/vocabulary.mjs
@@ -0,0 +1,36 @@
+/**
+ * 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: `` },
+ { value: 'bolder', label: 'Bolder', icon: `` },
+ { value: 'quieter', label: 'Quieter', icon: `` },
+ { value: 'distill', label: 'Distill', icon: `` },
+ { value: 'polish', label: 'Polish', icon: `` },
+ { value: 'typeset', label: 'Typeset', icon: `` },
+ { value: 'colorize', label: 'Colorize', icon: `` },
+ { value: 'layout', label: 'Layout', icon: `` },
+ { value: 'adapt', label: 'Adapt', icon: `` },
+ { value: 'animate', label: 'Animate', icon: `` },
+ { value: 'delight', label: 'Delight', icon: `` },
+ { value: 'overdrive', label: 'Overdrive', icon: `` },
+];
+
+// Action values accepted by the live event protocol, in palette order.
+export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.trae-cn/skills/impeccable/scripts/context-signals.mjs b/.trae-cn/skills/impeccable/scripts/context-signals.mjs
index c03843812..f631d7561 100644
--- a/.trae-cn/skills/impeccable/scripts/context-signals.mjs
+++ b/.trae-cn/skills/impeccable/scripts/context-signals.mjs
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
diff --git a/.trae-cn/skills/impeccable/scripts/critique-storage.mjs b/.trae-cn/skills/impeccable/scripts/critique-storage.mjs
index eba12383e..645628c8c 100644
--- a/.trae-cn/skills/impeccable/scripts/critique-storage.mjs
+++ b/.trae-cn/skills/impeccable/scripts/critique-storage.mjs
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
diff --git a/.trae-cn/skills/impeccable/scripts/design-parser.mjs b/.trae-cn/skills/impeccable/scripts/lib/design-parser.mjs
similarity index 100%
rename from .trae-cn/skills/impeccable/scripts/design-parser.mjs
rename to .trae-cn/skills/impeccable/scripts/lib/design-parser.mjs
diff --git a/.trae-cn/skills/impeccable/scripts/impeccable-paths.mjs b/.trae-cn/skills/impeccable/scripts/lib/impeccable-paths.mjs
similarity index 100%
rename from .trae-cn/skills/impeccable/scripts/impeccable-paths.mjs
rename to .trae-cn/skills/impeccable/scripts/lib/impeccable-paths.mjs
diff --git a/.trae-cn/skills/impeccable/scripts/is-generated.mjs b/.trae-cn/skills/impeccable/scripts/lib/is-generated.mjs
similarity index 100%
rename from .trae-cn/skills/impeccable/scripts/is-generated.mjs
rename to .trae-cn/skills/impeccable/scripts/lib/is-generated.mjs
diff --git a/.trae-cn/skills/impeccable/scripts/live-accept.mjs b/.trae-cn/skills/impeccable/scripts/live-accept.mjs
index 5e8687187..278dfbb2e 100644
--- a/.trae-cn/skills/impeccable/scripts/live-accept.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-accept.mjs
@@ -15,14 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.trae-cn/skills/impeccable/scripts/live-browser.js b/.trae-cn/skills/impeccable/scripts/live-browser.js
index 0a533945f..9c4b6c21e 100644
--- a/.trae-cn/skills/impeccable/scripts/live-browser.js
+++ b/.trae-cn/skills/impeccable/scripts/live-browser.js
@@ -82,7 +82,7 @@
]);
// Command vocabulary (values + labels + icons) comes from the canonical source,
- // skill/scripts/live-vocabulary.mjs, which live-server.mjs serializes into
+ // skill/scripts/live/vocabulary.mjs, which live-server.mjs serializes into
// window.__IMPECCABLE_VOCAB__ when it serves /live.js (same injection path as
// the token/port above, so it is always present here). The icons stack above
// each chip label and recolor to C.brand when selected (strokes use
@@ -1216,7 +1216,7 @@
: (focused ? BP.accentSoft : BP.hairline);
}
- // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs)
+ // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs)
function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
diff --git a/.trae-cn/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.trae-cn/skills/impeccable/scripts/live-commit-manual-edits.mjs
index 44bc5ea4b..8bde46b50 100644
--- a/.trae-cn/skills/impeccable/scripts/live-commit-manual-edits.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-commit-manual-edits.mjs
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
-import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
-import { isGeneratedFile } from './is-generated.mjs';
+import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
diff --git a/.trae-cn/skills/impeccable/scripts/live-complete.mjs b/.trae-cn/skills/impeccable/scripts/live-complete.mjs
index 78155af85..1cc4e7543 100644
--- a/.trae-cn/skills/impeccable/scripts/live-complete.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-complete.mjs
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
diff --git a/.trae-cn/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.trae-cn/skills/impeccable/scripts/live-discard-manual-edits.mjs
index 435f22413..aa669fd20 100644
--- a/.trae-cn/skills/impeccable/scripts/live-discard-manual-edits.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-discard-manual-edits.mjs
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
-import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
+import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
diff --git a/.trae-cn/skills/impeccable/scripts/live-event-validation.mjs b/.trae-cn/skills/impeccable/scripts/live-event-validation.mjs
deleted file mode 100644
index 15ae3265d..000000000
--- a/.trae-cn/skills/impeccable/scripts/live-event-validation.mjs
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * Shared event validation for the live helper server.
- * Extracted for unit testing (insert mode rules).
- */
-
-import { canCreateInsert } from './live-insert-ui.mjs';
-
-// The accepted visual action values come from the canonical vocabulary so the
-// validator, the picker UI, and the marketing demo never drift. Imported (not
-// just re-exported) so it is also in scope for the validators below.
-import { VISUAL_ACTIONS } from './live-vocabulary.mjs';
-export { VISUAL_ACTIONS };
-
-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 = ['<', '{', '}', '`'];
-
-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;
-}
-
-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 '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;
- default:
- return 'Unknown event type: ' + msg.type;
- }
-}
diff --git a/.trae-cn/skills/impeccable/scripts/live-inject.mjs b/.trae-cn/skills/impeccable/scripts/live-inject.mjs
index 3a1f36e46..c006ad09e 100644
--- a/.trae-cn/skills/impeccable/scripts/live-inject.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-inject.mjs
@@ -16,12 +16,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { resolveLiveConfigPath } from './impeccable-paths.mjs';
+import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
-} from './live-sveltekit-adapter.mjs';
+} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
diff --git a/.trae-cn/skills/impeccable/scripts/live-insert.mjs b/.trae-cn/skills/impeccable/scripts/live-insert.mjs
index 0658e9914..0ed3cafea 100644
--- a/.trae-cn/skills/impeccable/scripts/live-insert.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-insert.mjs
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -25,7 +25,7 @@ import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
diff --git a/.trae-cn/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.trae-cn/skills/impeccable/scripts/live-manual-edit-evidence.mjs
index 860278b73..dd10e96dc 100644
--- a/.trae-cn/skills/impeccable/scripts/live-manual-edit-evidence.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-manual-edit-evidence.mjs
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
diff --git a/.trae-cn/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.trae-cn/skills/impeccable/scripts/live-manual-edits-buffer.mjs
deleted file mode 100644
index 9e3dcf455..000000000
--- a/.trae-cn/skills/impeccable/scripts/live-manual-edits-buffer.mjs
+++ /dev/null
@@ -1,152 +0,0 @@
-/**
- * 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 './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;
-}
diff --git a/.trae-cn/skills/impeccable/scripts/live-poll.mjs b/.trae-cn/skills/impeccable/scripts/live-poll.mjs
index 1e1259fbc..b031055fe 100644
--- a/.trae-cn/skills/impeccable/scripts/live-poll.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-poll.mjs
@@ -12,8 +12,8 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
diff --git a/.trae-cn/skills/impeccable/scripts/live-resume.mjs b/.trae-cn/skills/impeccable/scripts/live-resume.mjs
index e54831f12..74284d48a 100644
--- a/.trae-cn/skills/impeccable/scripts/live-resume.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-resume.mjs
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs
index d9e06f449..187af0ac1 100644
--- a/.trae-cn/skills/impeccable/scripts/live-server.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs
@@ -20,11 +20,11 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
-import { parseDesignMd } from './design-parser.mjs';
+import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { validateEvent } from './live-event-validation.mjs';
-import { LIVE_COMMANDS } from './live-vocabulary.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { validateEvent } from './live/event-validation.mjs';
+import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -33,20 +33,20 @@ import {
removeLiveServerInfo,
resolveDesignSidecarPath,
writeLiveServerInfo,
-} from './impeccable-paths.mjs';
+} from './lib/impeccable-paths.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
-} from './live-manual-edits-buffer.mjs';
+} from './live/manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
diff --git a/.trae-cn/skills/impeccable/scripts/live-session-store.mjs b/.trae-cn/skills/impeccable/scripts/live-session-store.mjs
deleted file mode 100644
index 5ec4d34d4..000000000
--- a/.trae-cn/skills/impeccable/scripts/live-session-store.mjs
+++ /dev/null
@@ -1,289 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
-
-const COMPLETED_PHASES = new Set(['completed', 'discarded']);
-
-export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
- const rootDir = getLiveSessionsDir(cwd);
- const legacyRootDir = getLegacyLiveSessionsDir(cwd);
- fs.mkdirSync(rootDir, { recursive: true });
- const snapshotCache = new Map();
-
- function loadCachedOrRebuild(id) {
- const cached = snapshotCache.get(id);
- if (cached) return cached;
- const journalPath = getReadableJournalPath(id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- return rebuilt;
- }
-
- 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;
- }
-
- return {
- rootDir,
- legacyRootDir,
- appendEvent(event) {
- const normalized = normalizeEvent(event, sessionId);
- const journalPath = getJournalPath(rootDir, normalized.id);
- const snapshotPath = getSnapshotPath(rootDir, normalized.id);
- const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
- if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
- fs.copyFileSync(legacyJournalPath, journalPath);
- }
- const prior = loadCachedOrRebuild(normalized.id);
- const seq = prior.nextSeq;
- const entry = {
- seq,
- 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, prior.diagnostics);
- snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
- writeSnapshot(snapshotPath, next);
- return next;
- },
- getSnapshot(id = sessionId, opts = {}) {
- if (!id) throw new Error('session id required');
- const journalPath = getReadableJournalPath(id);
- const snapshotPath = getSnapshotPath(rootDir, id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- writeSnapshot(snapshotPath, rebuilt.snapshot);
- if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
- return rebuilt.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));
- }
- }
- return [...ids]
- .sort()
- .map((id) => this.getSnapshot(id))
- .filter(Boolean);
- },
- };
-}
-
-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 safeSessionId(id) {
- if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
- return id;
-}
-
-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,
- activeOwner: null,
- sourceMarkers: {},
- fallbackMode: null,
- annotationArtifacts: [],
- diagnostics: [],
- updatedAt: 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, inheritedDiagnostics = []) {
- const event = entry.event || entry;
- const next = {
- ...snapshot,
- paramValues: { ...(snapshot.paramValues || {}) },
- sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
- annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
- diagnostics: [...(snapshot.diagnostics || [])],
- updatedAt: entry.ts || new Date().toISOString(),
- };
-
- if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
- next.diagnostics = [...inheritedDiagnostics];
- }
-
- 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);
- if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
- break;
- case 'variants_ready':
- case 'agent_done':
- next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
- 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.',
- });
- }
- break;
- case 'checkpoint':
- if (COMPLETED_PHASES.has(next.phase)) {
- next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
- break;
- }
- if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
- next.phase = event.phase ?? next.phase;
- next.checkpointRevision = event.revision ?? next.checkpointRevision;
- next.activeOwner = event.owner ?? next.activeOwner;
- next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
- next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
- next.sourceFile = event.sourceFile ?? next.sourceFile;
- next.previewFile = event.previewFile ?? next.previewFile;
- next.previewMode = event.previewMode ?? next.previewMode;
- if (event.paramValues) next.paramValues = { ...event.paramValues };
- } else {
- next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
- }
- break;
- case 'accept':
- case 'accept_intent':
- next.phase = 'accept_requested';
- 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 '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.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':
- 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) {
- fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
-}
diff --git a/.trae-cn/skills/impeccable/scripts/live-status.mjs b/.trae-cn/skills/impeccable/scripts/live-status.mjs
index a7009cc29..f7e464999 100644
--- a/.trae-cn/skills/impeccable/scripts/live-status.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-status.mjs
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
diff --git a/.trae-cn/skills/impeccable/scripts/live-vocabulary.mjs b/.trae-cn/skills/impeccable/scripts/live-vocabulary.mjs
deleted file mode 100644
index 1123d41c4..000000000
--- a/.trae-cn/skills/impeccable/scripts/live-vocabulary.mjs
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * 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: `` },
- { value: 'bolder', label: 'Bolder', icon: `` },
- { value: 'quieter', label: 'Quieter', icon: `` },
- { value: 'distill', label: 'Distill', icon: `` },
- { value: 'polish', label: 'Polish', icon: `` },
- { value: 'typeset', label: 'Typeset', icon: `` },
- { value: 'colorize', label: 'Colorize', icon: `` },
- { value: 'layout', label: 'Layout', icon: `` },
- { value: 'adapt', label: 'Adapt', icon: `` },
- { value: 'animate', label: 'Animate', icon: `` },
- { value: 'delight', label: 'Delight', icon: `` },
- { value: 'overdrive', label: 'Overdrive', icon: `` },
-];
-
-// Action values accepted by the live event protocol, in palette order.
-export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.trae-cn/skills/impeccable/scripts/live-wrap.mjs b/.trae-cn/skills/impeccable/scripts/live-wrap.mjs
index a83cbcc56..c3f00f3a4 100644
--- a/.trae-cn/skills/impeccable/scripts/live-wrap.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live-wrap.mjs
@@ -13,13 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.trae-cn/skills/impeccable/scripts/live.mjs b/.trae-cn/skills/impeccable/scripts/live.mjs
index 8acd300ed..0992da1bd 100644
--- a/.trae-cn/skills/impeccable/scripts/live.mjs
+++ b/.trae-cn/skills/impeccable/scripts/live.mjs
@@ -23,7 +23,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
diff --git a/.trae-cn/skills/impeccable/scripts/live-completion.mjs b/.trae-cn/skills/impeccable/scripts/live/completion.mjs
similarity index 100%
rename from .trae-cn/skills/impeccable/scripts/live-completion.mjs
rename to .trae-cn/skills/impeccable/scripts/live/completion.mjs
diff --git a/.trae-cn/skills/impeccable/scripts/live/event-validation.mjs b/.trae-cn/skills/impeccable/scripts/live/event-validation.mjs
new file mode 100644
index 000000000..0897b1317
--- /dev/null
+++ b/.trae-cn/skills/impeccable/scripts/live/event-validation.mjs
@@ -0,0 +1,137 @@
+/**
+ * Shared event validation for the live helper server.
+ * Extracted for unit testing (insert mode rules).
+ */
+
+import { canCreateInsert } from './insert-ui.mjs';
+
+// The accepted visual action values come from the canonical vocabulary so the
+// validator, the picker UI, and the marketing demo never drift. Imported (not
+// just re-exported) so it is also in scope for the validators below.
+import { VISUAL_ACTIONS } from './vocabulary.mjs';
+export { VISUAL_ACTIONS };
+
+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 = ['<', '{', '}', '`'];
+
+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;
+}
+
+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 '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;
+ default:
+ return 'Unknown event type: ' + msg.type;
+ }
+}
diff --git a/.trae-cn/skills/impeccable/scripts/live-insert-ui.mjs b/.trae-cn/skills/impeccable/scripts/live/insert-ui.mjs
similarity index 100%
rename from .trae-cn/skills/impeccable/scripts/live-insert-ui.mjs
rename to .trae-cn/skills/impeccable/scripts/live/insert-ui.mjs
diff --git a/.trae-cn/skills/impeccable/scripts/live/manual-edits-buffer.mjs b/.trae-cn/skills/impeccable/scripts/live/manual-edits-buffer.mjs
new file mode 100644
index 000000000..d96ebbe34
--- /dev/null
+++ b/.trae-cn/skills/impeccable/scripts/live/manual-edits-buffer.mjs
@@ -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;
+}
diff --git a/.trae-cn/skills/impeccable/scripts/live/session-store.mjs b/.trae-cn/skills/impeccable/scripts/live/session-store.mjs
new file mode 100644
index 000000000..affba67c9
--- /dev/null
+++ b/.trae-cn/skills/impeccable/scripts/live/session-store.mjs
@@ -0,0 +1,289 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
+
+const COMPLETED_PHASES = new Set(['completed', 'discarded']);
+
+export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
+ const rootDir = getLiveSessionsDir(cwd);
+ const legacyRootDir = getLegacyLiveSessionsDir(cwd);
+ fs.mkdirSync(rootDir, { recursive: true });
+ const snapshotCache = new Map();
+
+ function loadCachedOrRebuild(id) {
+ const cached = snapshotCache.get(id);
+ if (cached) return cached;
+ const journalPath = getReadableJournalPath(id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ return rebuilt;
+ }
+
+ 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;
+ }
+
+ return {
+ rootDir,
+ legacyRootDir,
+ appendEvent(event) {
+ const normalized = normalizeEvent(event, sessionId);
+ const journalPath = getJournalPath(rootDir, normalized.id);
+ const snapshotPath = getSnapshotPath(rootDir, normalized.id);
+ const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
+ if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
+ fs.copyFileSync(legacyJournalPath, journalPath);
+ }
+ const prior = loadCachedOrRebuild(normalized.id);
+ const seq = prior.nextSeq;
+ const entry = {
+ seq,
+ 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, prior.diagnostics);
+ snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
+ writeSnapshot(snapshotPath, next);
+ return next;
+ },
+ getSnapshot(id = sessionId, opts = {}) {
+ if (!id) throw new Error('session id required');
+ const journalPath = getReadableJournalPath(id);
+ const snapshotPath = getSnapshotPath(rootDir, id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ writeSnapshot(snapshotPath, rebuilt.snapshot);
+ if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
+ return rebuilt.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));
+ }
+ }
+ return [...ids]
+ .sort()
+ .map((id) => this.getSnapshot(id))
+ .filter(Boolean);
+ },
+ };
+}
+
+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 safeSessionId(id) {
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
+ return id;
+}
+
+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,
+ activeOwner: null,
+ sourceMarkers: {},
+ fallbackMode: null,
+ annotationArtifacts: [],
+ diagnostics: [],
+ updatedAt: 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, inheritedDiagnostics = []) {
+ const event = entry.event || entry;
+ const next = {
+ ...snapshot,
+ paramValues: { ...(snapshot.paramValues || {}) },
+ sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
+ annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
+ diagnostics: [...(snapshot.diagnostics || [])],
+ updatedAt: entry.ts || new Date().toISOString(),
+ };
+
+ if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
+ next.diagnostics = [...inheritedDiagnostics];
+ }
+
+ 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);
+ if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
+ break;
+ case 'variants_ready':
+ case 'agent_done':
+ next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
+ 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.',
+ });
+ }
+ break;
+ case 'checkpoint':
+ if (COMPLETED_PHASES.has(next.phase)) {
+ next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
+ break;
+ }
+ if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
+ next.phase = event.phase ?? next.phase;
+ next.checkpointRevision = event.revision ?? next.checkpointRevision;
+ next.activeOwner = event.owner ?? next.activeOwner;
+ next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
+ next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
+ next.sourceFile = event.sourceFile ?? next.sourceFile;
+ next.previewFile = event.previewFile ?? next.previewFile;
+ next.previewMode = event.previewMode ?? next.previewMode;
+ if (event.paramValues) next.paramValues = { ...event.paramValues };
+ } else {
+ next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
+ }
+ break;
+ case 'accept':
+ case 'accept_intent':
+ next.phase = 'accept_requested';
+ 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 '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.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':
+ 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) {
+ fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
+}
diff --git a/.trae-cn/skills/impeccable/scripts/live-svelte-component.mjs b/.trae-cn/skills/impeccable/scripts/live/svelte-component.mjs
similarity index 100%
rename from .trae-cn/skills/impeccable/scripts/live-svelte-component.mjs
rename to .trae-cn/skills/impeccable/scripts/live/svelte-component.mjs
diff --git a/.trae-cn/skills/impeccable/scripts/live-sveltekit-adapter.mjs b/.trae-cn/skills/impeccable/scripts/live/sveltekit-adapter.mjs
similarity index 100%
rename from .trae-cn/skills/impeccable/scripts/live-sveltekit-adapter.mjs
rename to .trae-cn/skills/impeccable/scripts/live/sveltekit-adapter.mjs
diff --git a/.trae-cn/skills/impeccable/scripts/live-ui-core.mjs b/.trae-cn/skills/impeccable/scripts/live/ui-core.mjs
similarity index 100%
rename from .trae-cn/skills/impeccable/scripts/live-ui-core.mjs
rename to .trae-cn/skills/impeccable/scripts/live/ui-core.mjs
diff --git a/.trae-cn/skills/impeccable/scripts/live/vocabulary.mjs b/.trae-cn/skills/impeccable/scripts/live/vocabulary.mjs
new file mode 100644
index 000000000..5c7b0b713
--- /dev/null
+++ b/.trae-cn/skills/impeccable/scripts/live/vocabulary.mjs
@@ -0,0 +1,36 @@
+/**
+ * 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: `` },
+ { value: 'bolder', label: 'Bolder', icon: `` },
+ { value: 'quieter', label: 'Quieter', icon: `` },
+ { value: 'distill', label: 'Distill', icon: `` },
+ { value: 'polish', label: 'Polish', icon: `` },
+ { value: 'typeset', label: 'Typeset', icon: `` },
+ { value: 'colorize', label: 'Colorize', icon: `` },
+ { value: 'layout', label: 'Layout', icon: `` },
+ { value: 'adapt', label: 'Adapt', icon: `` },
+ { value: 'animate', label: 'Animate', icon: `` },
+ { value: 'delight', label: 'Delight', icon: `` },
+ { value: 'overdrive', label: 'Overdrive', icon: `` },
+];
+
+// Action values accepted by the live event protocol, in palette order.
+export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.trae/skills/impeccable/scripts/context-signals.mjs b/.trae/skills/impeccable/scripts/context-signals.mjs
index c03843812..f631d7561 100644
--- a/.trae/skills/impeccable/scripts/context-signals.mjs
+++ b/.trae/skills/impeccable/scripts/context-signals.mjs
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
diff --git a/.trae/skills/impeccable/scripts/critique-storage.mjs b/.trae/skills/impeccable/scripts/critique-storage.mjs
index eba12383e..645628c8c 100644
--- a/.trae/skills/impeccable/scripts/critique-storage.mjs
+++ b/.trae/skills/impeccable/scripts/critique-storage.mjs
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
diff --git a/.trae/skills/impeccable/scripts/design-parser.mjs b/.trae/skills/impeccable/scripts/lib/design-parser.mjs
similarity index 100%
rename from .trae/skills/impeccable/scripts/design-parser.mjs
rename to .trae/skills/impeccable/scripts/lib/design-parser.mjs
diff --git a/.trae/skills/impeccable/scripts/impeccable-paths.mjs b/.trae/skills/impeccable/scripts/lib/impeccable-paths.mjs
similarity index 100%
rename from .trae/skills/impeccable/scripts/impeccable-paths.mjs
rename to .trae/skills/impeccable/scripts/lib/impeccable-paths.mjs
diff --git a/.trae/skills/impeccable/scripts/is-generated.mjs b/.trae/skills/impeccable/scripts/lib/is-generated.mjs
similarity index 100%
rename from .trae/skills/impeccable/scripts/is-generated.mjs
rename to .trae/skills/impeccable/scripts/lib/is-generated.mjs
diff --git a/.trae/skills/impeccable/scripts/live-accept.mjs b/.trae/skills/impeccable/scripts/live-accept.mjs
index 5e8687187..278dfbb2e 100644
--- a/.trae/skills/impeccable/scripts/live-accept.mjs
+++ b/.trae/skills/impeccable/scripts/live-accept.mjs
@@ -15,14 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.trae/skills/impeccable/scripts/live-browser.js b/.trae/skills/impeccable/scripts/live-browser.js
index 0a533945f..9c4b6c21e 100644
--- a/.trae/skills/impeccable/scripts/live-browser.js
+++ b/.trae/skills/impeccable/scripts/live-browser.js
@@ -82,7 +82,7 @@
]);
// Command vocabulary (values + labels + icons) comes from the canonical source,
- // skill/scripts/live-vocabulary.mjs, which live-server.mjs serializes into
+ // skill/scripts/live/vocabulary.mjs, which live-server.mjs serializes into
// window.__IMPECCABLE_VOCAB__ when it serves /live.js (same injection path as
// the token/port above, so it is always present here). The icons stack above
// each chip label and recolor to C.brand when selected (strokes use
@@ -1216,7 +1216,7 @@
: (focused ? BP.accentSoft : BP.hairline);
}
- // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs)
+ // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs)
function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
diff --git a/.trae/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.trae/skills/impeccable/scripts/live-commit-manual-edits.mjs
index 44bc5ea4b..8bde46b50 100644
--- a/.trae/skills/impeccable/scripts/live-commit-manual-edits.mjs
+++ b/.trae/skills/impeccable/scripts/live-commit-manual-edits.mjs
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
-import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
-import { isGeneratedFile } from './is-generated.mjs';
+import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
diff --git a/.trae/skills/impeccable/scripts/live-complete.mjs b/.trae/skills/impeccable/scripts/live-complete.mjs
index 78155af85..1cc4e7543 100644
--- a/.trae/skills/impeccable/scripts/live-complete.mjs
+++ b/.trae/skills/impeccable/scripts/live-complete.mjs
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
diff --git a/.trae/skills/impeccable/scripts/live-discard-manual-edits.mjs b/.trae/skills/impeccable/scripts/live-discard-manual-edits.mjs
index 435f22413..aa669fd20 100644
--- a/.trae/skills/impeccable/scripts/live-discard-manual-edits.mjs
+++ b/.trae/skills/impeccable/scripts/live-discard-manual-edits.mjs
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
-import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
+import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
diff --git a/.trae/skills/impeccable/scripts/live-event-validation.mjs b/.trae/skills/impeccable/scripts/live-event-validation.mjs
deleted file mode 100644
index 15ae3265d..000000000
--- a/.trae/skills/impeccable/scripts/live-event-validation.mjs
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * Shared event validation for the live helper server.
- * Extracted for unit testing (insert mode rules).
- */
-
-import { canCreateInsert } from './live-insert-ui.mjs';
-
-// The accepted visual action values come from the canonical vocabulary so the
-// validator, the picker UI, and the marketing demo never drift. Imported (not
-// just re-exported) so it is also in scope for the validators below.
-import { VISUAL_ACTIONS } from './live-vocabulary.mjs';
-export { VISUAL_ACTIONS };
-
-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 = ['<', '{', '}', '`'];
-
-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;
-}
-
-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 '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;
- default:
- return 'Unknown event type: ' + msg.type;
- }
-}
diff --git a/.trae/skills/impeccable/scripts/live-inject.mjs b/.trae/skills/impeccable/scripts/live-inject.mjs
index 3a1f36e46..c006ad09e 100644
--- a/.trae/skills/impeccable/scripts/live-inject.mjs
+++ b/.trae/skills/impeccable/scripts/live-inject.mjs
@@ -16,12 +16,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { resolveLiveConfigPath } from './impeccable-paths.mjs';
+import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
-} from './live-sveltekit-adapter.mjs';
+} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
diff --git a/.trae/skills/impeccable/scripts/live-insert.mjs b/.trae/skills/impeccable/scripts/live-insert.mjs
index 0658e9914..0ed3cafea 100644
--- a/.trae/skills/impeccable/scripts/live-insert.mjs
+++ b/.trae/skills/impeccable/scripts/live-insert.mjs
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -25,7 +25,7 @@ import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
diff --git a/.trae/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.trae/skills/impeccable/scripts/live-manual-edit-evidence.mjs
index 860278b73..dd10e96dc 100644
--- a/.trae/skills/impeccable/scripts/live-manual-edit-evidence.mjs
+++ b/.trae/skills/impeccable/scripts/live-manual-edit-evidence.mjs
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
diff --git a/.trae/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.trae/skills/impeccable/scripts/live-manual-edits-buffer.mjs
deleted file mode 100644
index 9e3dcf455..000000000
--- a/.trae/skills/impeccable/scripts/live-manual-edits-buffer.mjs
+++ /dev/null
@@ -1,152 +0,0 @@
-/**
- * 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 './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;
-}
diff --git a/.trae/skills/impeccable/scripts/live-poll.mjs b/.trae/skills/impeccable/scripts/live-poll.mjs
index 1e1259fbc..b031055fe 100644
--- a/.trae/skills/impeccable/scripts/live-poll.mjs
+++ b/.trae/skills/impeccable/scripts/live-poll.mjs
@@ -12,8 +12,8 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
diff --git a/.trae/skills/impeccable/scripts/live-resume.mjs b/.trae/skills/impeccable/scripts/live-resume.mjs
index e54831f12..74284d48a 100644
--- a/.trae/skills/impeccable/scripts/live-resume.mjs
+++ b/.trae/skills/impeccable/scripts/live-resume.mjs
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs
index d9e06f449..187af0ac1 100644
--- a/.trae/skills/impeccable/scripts/live-server.mjs
+++ b/.trae/skills/impeccable/scripts/live-server.mjs
@@ -20,11 +20,11 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
-import { parseDesignMd } from './design-parser.mjs';
+import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { validateEvent } from './live-event-validation.mjs';
-import { LIVE_COMMANDS } from './live-vocabulary.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { validateEvent } from './live/event-validation.mjs';
+import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -33,20 +33,20 @@ import {
removeLiveServerInfo,
resolveDesignSidecarPath,
writeLiveServerInfo,
-} from './impeccable-paths.mjs';
+} from './lib/impeccable-paths.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
-} from './live-manual-edits-buffer.mjs';
+} from './live/manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
diff --git a/.trae/skills/impeccable/scripts/live-session-store.mjs b/.trae/skills/impeccable/scripts/live-session-store.mjs
deleted file mode 100644
index 5ec4d34d4..000000000
--- a/.trae/skills/impeccable/scripts/live-session-store.mjs
+++ /dev/null
@@ -1,289 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
-
-const COMPLETED_PHASES = new Set(['completed', 'discarded']);
-
-export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
- const rootDir = getLiveSessionsDir(cwd);
- const legacyRootDir = getLegacyLiveSessionsDir(cwd);
- fs.mkdirSync(rootDir, { recursive: true });
- const snapshotCache = new Map();
-
- function loadCachedOrRebuild(id) {
- const cached = snapshotCache.get(id);
- if (cached) return cached;
- const journalPath = getReadableJournalPath(id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- return rebuilt;
- }
-
- 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;
- }
-
- return {
- rootDir,
- legacyRootDir,
- appendEvent(event) {
- const normalized = normalizeEvent(event, sessionId);
- const journalPath = getJournalPath(rootDir, normalized.id);
- const snapshotPath = getSnapshotPath(rootDir, normalized.id);
- const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
- if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
- fs.copyFileSync(legacyJournalPath, journalPath);
- }
- const prior = loadCachedOrRebuild(normalized.id);
- const seq = prior.nextSeq;
- const entry = {
- seq,
- 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, prior.diagnostics);
- snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
- writeSnapshot(snapshotPath, next);
- return next;
- },
- getSnapshot(id = sessionId, opts = {}) {
- if (!id) throw new Error('session id required');
- const journalPath = getReadableJournalPath(id);
- const snapshotPath = getSnapshotPath(rootDir, id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- writeSnapshot(snapshotPath, rebuilt.snapshot);
- if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
- return rebuilt.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));
- }
- }
- return [...ids]
- .sort()
- .map((id) => this.getSnapshot(id))
- .filter(Boolean);
- },
- };
-}
-
-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 safeSessionId(id) {
- if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
- return id;
-}
-
-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,
- activeOwner: null,
- sourceMarkers: {},
- fallbackMode: null,
- annotationArtifacts: [],
- diagnostics: [],
- updatedAt: 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, inheritedDiagnostics = []) {
- const event = entry.event || entry;
- const next = {
- ...snapshot,
- paramValues: { ...(snapshot.paramValues || {}) },
- sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
- annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
- diagnostics: [...(snapshot.diagnostics || [])],
- updatedAt: entry.ts || new Date().toISOString(),
- };
-
- if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
- next.diagnostics = [...inheritedDiagnostics];
- }
-
- 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);
- if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
- break;
- case 'variants_ready':
- case 'agent_done':
- next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
- 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.',
- });
- }
- break;
- case 'checkpoint':
- if (COMPLETED_PHASES.has(next.phase)) {
- next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
- break;
- }
- if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
- next.phase = event.phase ?? next.phase;
- next.checkpointRevision = event.revision ?? next.checkpointRevision;
- next.activeOwner = event.owner ?? next.activeOwner;
- next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
- next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
- next.sourceFile = event.sourceFile ?? next.sourceFile;
- next.previewFile = event.previewFile ?? next.previewFile;
- next.previewMode = event.previewMode ?? next.previewMode;
- if (event.paramValues) next.paramValues = { ...event.paramValues };
- } else {
- next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
- }
- break;
- case 'accept':
- case 'accept_intent':
- next.phase = 'accept_requested';
- 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 '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.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':
- 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) {
- fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
-}
diff --git a/.trae/skills/impeccable/scripts/live-status.mjs b/.trae/skills/impeccable/scripts/live-status.mjs
index a7009cc29..f7e464999 100644
--- a/.trae/skills/impeccable/scripts/live-status.mjs
+++ b/.trae/skills/impeccable/scripts/live-status.mjs
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
diff --git a/.trae/skills/impeccable/scripts/live-vocabulary.mjs b/.trae/skills/impeccable/scripts/live-vocabulary.mjs
deleted file mode 100644
index 1123d41c4..000000000
--- a/.trae/skills/impeccable/scripts/live-vocabulary.mjs
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * 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: `` },
- { value: 'bolder', label: 'Bolder', icon: `` },
- { value: 'quieter', label: 'Quieter', icon: `` },
- { value: 'distill', label: 'Distill', icon: `` },
- { value: 'polish', label: 'Polish', icon: `` },
- { value: 'typeset', label: 'Typeset', icon: `` },
- { value: 'colorize', label: 'Colorize', icon: `` },
- { value: 'layout', label: 'Layout', icon: `` },
- { value: 'adapt', label: 'Adapt', icon: `` },
- { value: 'animate', label: 'Animate', icon: `` },
- { value: 'delight', label: 'Delight', icon: `` },
- { value: 'overdrive', label: 'Overdrive', icon: `` },
-];
-
-// Action values accepted by the live event protocol, in palette order.
-export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/.trae/skills/impeccable/scripts/live-wrap.mjs b/.trae/skills/impeccable/scripts/live-wrap.mjs
index a83cbcc56..c3f00f3a4 100644
--- a/.trae/skills/impeccable/scripts/live-wrap.mjs
+++ b/.trae/skills/impeccable/scripts/live-wrap.mjs
@@ -13,13 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/.trae/skills/impeccable/scripts/live.mjs b/.trae/skills/impeccable/scripts/live.mjs
index 8acd300ed..0992da1bd 100644
--- a/.trae/skills/impeccable/scripts/live.mjs
+++ b/.trae/skills/impeccable/scripts/live.mjs
@@ -23,7 +23,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
diff --git a/.trae/skills/impeccable/scripts/live-completion.mjs b/.trae/skills/impeccable/scripts/live/completion.mjs
similarity index 100%
rename from .trae/skills/impeccable/scripts/live-completion.mjs
rename to .trae/skills/impeccable/scripts/live/completion.mjs
diff --git a/.trae/skills/impeccable/scripts/live/event-validation.mjs b/.trae/skills/impeccable/scripts/live/event-validation.mjs
new file mode 100644
index 000000000..0897b1317
--- /dev/null
+++ b/.trae/skills/impeccable/scripts/live/event-validation.mjs
@@ -0,0 +1,137 @@
+/**
+ * Shared event validation for the live helper server.
+ * Extracted for unit testing (insert mode rules).
+ */
+
+import { canCreateInsert } from './insert-ui.mjs';
+
+// The accepted visual action values come from the canonical vocabulary so the
+// validator, the picker UI, and the marketing demo never drift. Imported (not
+// just re-exported) so it is also in scope for the validators below.
+import { VISUAL_ACTIONS } from './vocabulary.mjs';
+export { VISUAL_ACTIONS };
+
+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 = ['<', '{', '}', '`'];
+
+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;
+}
+
+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 '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;
+ default:
+ return 'Unknown event type: ' + msg.type;
+ }
+}
diff --git a/.trae/skills/impeccable/scripts/live-insert-ui.mjs b/.trae/skills/impeccable/scripts/live/insert-ui.mjs
similarity index 100%
rename from .trae/skills/impeccable/scripts/live-insert-ui.mjs
rename to .trae/skills/impeccable/scripts/live/insert-ui.mjs
diff --git a/.trae/skills/impeccable/scripts/live/manual-edits-buffer.mjs b/.trae/skills/impeccable/scripts/live/manual-edits-buffer.mjs
new file mode 100644
index 000000000..d96ebbe34
--- /dev/null
+++ b/.trae/skills/impeccable/scripts/live/manual-edits-buffer.mjs
@@ -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;
+}
diff --git a/.trae/skills/impeccable/scripts/live/session-store.mjs b/.trae/skills/impeccable/scripts/live/session-store.mjs
new file mode 100644
index 000000000..affba67c9
--- /dev/null
+++ b/.trae/skills/impeccable/scripts/live/session-store.mjs
@@ -0,0 +1,289 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
+
+const COMPLETED_PHASES = new Set(['completed', 'discarded']);
+
+export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
+ const rootDir = getLiveSessionsDir(cwd);
+ const legacyRootDir = getLegacyLiveSessionsDir(cwd);
+ fs.mkdirSync(rootDir, { recursive: true });
+ const snapshotCache = new Map();
+
+ function loadCachedOrRebuild(id) {
+ const cached = snapshotCache.get(id);
+ if (cached) return cached;
+ const journalPath = getReadableJournalPath(id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ return rebuilt;
+ }
+
+ 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;
+ }
+
+ return {
+ rootDir,
+ legacyRootDir,
+ appendEvent(event) {
+ const normalized = normalizeEvent(event, sessionId);
+ const journalPath = getJournalPath(rootDir, normalized.id);
+ const snapshotPath = getSnapshotPath(rootDir, normalized.id);
+ const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
+ if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
+ fs.copyFileSync(legacyJournalPath, journalPath);
+ }
+ const prior = loadCachedOrRebuild(normalized.id);
+ const seq = prior.nextSeq;
+ const entry = {
+ seq,
+ 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, prior.diagnostics);
+ snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
+ writeSnapshot(snapshotPath, next);
+ return next;
+ },
+ getSnapshot(id = sessionId, opts = {}) {
+ if (!id) throw new Error('session id required');
+ const journalPath = getReadableJournalPath(id);
+ const snapshotPath = getSnapshotPath(rootDir, id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ writeSnapshot(snapshotPath, rebuilt.snapshot);
+ if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
+ return rebuilt.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));
+ }
+ }
+ return [...ids]
+ .sort()
+ .map((id) => this.getSnapshot(id))
+ .filter(Boolean);
+ },
+ };
+}
+
+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 safeSessionId(id) {
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
+ return id;
+}
+
+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,
+ activeOwner: null,
+ sourceMarkers: {},
+ fallbackMode: null,
+ annotationArtifacts: [],
+ diagnostics: [],
+ updatedAt: 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, inheritedDiagnostics = []) {
+ const event = entry.event || entry;
+ const next = {
+ ...snapshot,
+ paramValues: { ...(snapshot.paramValues || {}) },
+ sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
+ annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
+ diagnostics: [...(snapshot.diagnostics || [])],
+ updatedAt: entry.ts || new Date().toISOString(),
+ };
+
+ if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
+ next.diagnostics = [...inheritedDiagnostics];
+ }
+
+ 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);
+ if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
+ break;
+ case 'variants_ready':
+ case 'agent_done':
+ next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
+ 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.',
+ });
+ }
+ break;
+ case 'checkpoint':
+ if (COMPLETED_PHASES.has(next.phase)) {
+ next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
+ break;
+ }
+ if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
+ next.phase = event.phase ?? next.phase;
+ next.checkpointRevision = event.revision ?? next.checkpointRevision;
+ next.activeOwner = event.owner ?? next.activeOwner;
+ next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
+ next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
+ next.sourceFile = event.sourceFile ?? next.sourceFile;
+ next.previewFile = event.previewFile ?? next.previewFile;
+ next.previewMode = event.previewMode ?? next.previewMode;
+ if (event.paramValues) next.paramValues = { ...event.paramValues };
+ } else {
+ next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
+ }
+ break;
+ case 'accept':
+ case 'accept_intent':
+ next.phase = 'accept_requested';
+ 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 '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.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':
+ 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) {
+ fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
+}
diff --git a/.trae/skills/impeccable/scripts/live-svelte-component.mjs b/.trae/skills/impeccable/scripts/live/svelte-component.mjs
similarity index 100%
rename from .trae/skills/impeccable/scripts/live-svelte-component.mjs
rename to .trae/skills/impeccable/scripts/live/svelte-component.mjs
diff --git a/.trae/skills/impeccable/scripts/live-sveltekit-adapter.mjs b/.trae/skills/impeccable/scripts/live/sveltekit-adapter.mjs
similarity index 100%
rename from .trae/skills/impeccable/scripts/live-sveltekit-adapter.mjs
rename to .trae/skills/impeccable/scripts/live/sveltekit-adapter.mjs
diff --git a/.trae/skills/impeccable/scripts/live-ui-core.mjs b/.trae/skills/impeccable/scripts/live/ui-core.mjs
similarity index 100%
rename from .trae/skills/impeccable/scripts/live-ui-core.mjs
rename to .trae/skills/impeccable/scripts/live/ui-core.mjs
diff --git a/.trae/skills/impeccable/scripts/live/vocabulary.mjs b/.trae/skills/impeccable/scripts/live/vocabulary.mjs
new file mode 100644
index 000000000..5c7b0b713
--- /dev/null
+++ b/.trae/skills/impeccable/scripts/live/vocabulary.mjs
@@ -0,0 +1,36 @@
+/**
+ * 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: `` },
+ { value: 'bolder', label: 'Bolder', icon: `` },
+ { value: 'quieter', label: 'Quieter', icon: `` },
+ { value: 'distill', label: 'Distill', icon: `` },
+ { value: 'polish', label: 'Polish', icon: `` },
+ { value: 'typeset', label: 'Typeset', icon: `` },
+ { value: 'colorize', label: 'Colorize', icon: `` },
+ { value: 'layout', label: 'Layout', icon: `` },
+ { value: 'adapt', label: 'Adapt', icon: `` },
+ { value: 'animate', label: 'Animate', icon: `` },
+ { value: 'delight', label: 'Delight', icon: `` },
+ { value: 'overdrive', label: 'Overdrive', icon: `` },
+];
+
+// Action values accepted by the live event protocol, in palette order.
+export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/plugin/skills/impeccable/scripts/context-signals.mjs b/plugin/skills/impeccable/scripts/context-signals.mjs
index c03843812..f631d7561 100644
--- a/plugin/skills/impeccable/scripts/context-signals.mjs
+++ b/plugin/skills/impeccable/scripts/context-signals.mjs
@@ -22,7 +22,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execFileSync } from 'node:child_process';
import { loadContext, extractRegister } from './context.mjs';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
/** Is there code here at all, or just context files / an empty repo? */
function hasCode(cwd) {
diff --git a/plugin/skills/impeccable/scripts/critique-storage.mjs b/plugin/skills/impeccable/scripts/critique-storage.mjs
index eba12383e..645628c8c 100644
--- a/plugin/skills/impeccable/scripts/critique-storage.mjs
+++ b/plugin/skills/impeccable/scripts/critique-storage.mjs
@@ -28,7 +28,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
-import { getCritiqueDir } from './impeccable-paths.mjs';
+import { getCritiqueDir } from './lib/impeccable-paths.mjs';
const SLUG_MAX = 50;
diff --git a/plugin/skills/impeccable/scripts/design-parser.mjs b/plugin/skills/impeccable/scripts/lib/design-parser.mjs
similarity index 100%
rename from plugin/skills/impeccable/scripts/design-parser.mjs
rename to plugin/skills/impeccable/scripts/lib/design-parser.mjs
diff --git a/plugin/skills/impeccable/scripts/impeccable-paths.mjs b/plugin/skills/impeccable/scripts/lib/impeccable-paths.mjs
similarity index 100%
rename from plugin/skills/impeccable/scripts/impeccable-paths.mjs
rename to plugin/skills/impeccable/scripts/lib/impeccable-paths.mjs
diff --git a/plugin/skills/impeccable/scripts/is-generated.mjs b/plugin/skills/impeccable/scripts/lib/is-generated.mjs
similarity index 100%
rename from plugin/skills/impeccable/scripts/is-generated.mjs
rename to plugin/skills/impeccable/scripts/lib/is-generated.mjs
diff --git a/plugin/skills/impeccable/scripts/live-accept.mjs b/plugin/skills/impeccable/scripts/live-accept.mjs
index 5e8687187..278dfbb2e 100644
--- a/plugin/skills/impeccable/scripts/live-accept.mjs
+++ b/plugin/skills/impeccable/scripts/live-accept.mjs
@@ -15,14 +15,14 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/plugin/skills/impeccable/scripts/live-browser.js b/plugin/skills/impeccable/scripts/live-browser.js
index 0a533945f..9c4b6c21e 100644
--- a/plugin/skills/impeccable/scripts/live-browser.js
+++ b/plugin/skills/impeccable/scripts/live-browser.js
@@ -82,7 +82,7 @@
]);
// Command vocabulary (values + labels + icons) comes from the canonical source,
- // skill/scripts/live-vocabulary.mjs, which live-server.mjs serializes into
+ // skill/scripts/live/vocabulary.mjs, which live-server.mjs serializes into
// window.__IMPECCABLE_VOCAB__ when it serves /live.js (same injection path as
// the token/port above, so it is always present here). The icons stack above
// each chip label and recolor to C.brand when selected (strokes use
@@ -1216,7 +1216,7 @@
: (focused ? BP.accentSoft : BP.hairline);
}
- // Insert mode helpers (mirrors skill/scripts/live-insert-ui.mjs)
+ // Insert mode helpers (mirrors skill/scripts/live/insert-ui.mjs)
function detectInsertAxisFromStyle(style) {
const display = style?.display || 'block';
diff --git a/plugin/skills/impeccable/scripts/live-commit-manual-edits.mjs b/plugin/skills/impeccable/scripts/live-commit-manual-edits.mjs
index 44bc5ea4b..8bde46b50 100644
--- a/plugin/skills/impeccable/scripts/live-commit-manual-edits.mjs
+++ b/plugin/skills/impeccable/scripts/live-commit-manual-edits.mjs
@@ -16,8 +16,8 @@
*/
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
-import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live-manual-edits-buffer.mjs';
-import { isGeneratedFile } from './is-generated.mjs';
+import { readBuffer, readBufferStrict, writeBuffer, countByPage } from './live/manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
runCopyEditBatchAgent,
runCopyEditPostApplyChecks,
diff --git a/plugin/skills/impeccable/scripts/live-complete.mjs b/plugin/skills/impeccable/scripts/live-complete.mjs
index 78155af85..1cc4e7543 100644
--- a/plugin/skills/impeccable/scripts/live-complete.mjs
+++ b/plugin/skills/impeccable/scripts/live-complete.mjs
@@ -3,8 +3,8 @@
* Canonical durable completion acknowledgement for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
function parseArgs(argv) {
const out = { status: 'complete' };
diff --git a/plugin/skills/impeccable/scripts/live-discard-manual-edits.mjs b/plugin/skills/impeccable/scripts/live-discard-manual-edits.mjs
index 435f22413..aa669fd20 100644
--- a/plugin/skills/impeccable/scripts/live-discard-manual-edits.mjs
+++ b/plugin/skills/impeccable/scripts/live-discard-manual-edits.mjs
@@ -16,7 +16,7 @@
* Output JSON: { discarded: N, entries: [...discardedEntries], totalCount: N }
*/
-import { readBuffer, removeEntries, truncateBuffer } from './live-manual-edits-buffer.mjs';
+import { readBuffer, removeEntries, truncateBuffer } from './live/manual-edits-buffer.mjs';
function argVal(args, name) {
const prefix = name + '=';
diff --git a/plugin/skills/impeccable/scripts/live-event-validation.mjs b/plugin/skills/impeccable/scripts/live-event-validation.mjs
deleted file mode 100644
index 15ae3265d..000000000
--- a/plugin/skills/impeccable/scripts/live-event-validation.mjs
+++ /dev/null
@@ -1,137 +0,0 @@
-/**
- * Shared event validation for the live helper server.
- * Extracted for unit testing (insert mode rules).
- */
-
-import { canCreateInsert } from './live-insert-ui.mjs';
-
-// The accepted visual action values come from the canonical vocabulary so the
-// validator, the picker UI, and the marketing demo never drift. Imported (not
-// just re-exported) so it is also in scope for the validators below.
-import { VISUAL_ACTIONS } from './live-vocabulary.mjs';
-export { VISUAL_ACTIONS };
-
-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 = ['<', '{', '}', '`'];
-
-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;
-}
-
-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 '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;
- default:
- return 'Unknown event type: ' + msg.type;
- }
-}
diff --git a/plugin/skills/impeccable/scripts/live-inject.mjs b/plugin/skills/impeccable/scripts/live-inject.mjs
index 3a1f36e46..c006ad09e 100644
--- a/plugin/skills/impeccable/scripts/live-inject.mjs
+++ b/plugin/skills/impeccable/scripts/live-inject.mjs
@@ -16,12 +16,12 @@
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { resolveLiveConfigPath } from './impeccable-paths.mjs';
+import { resolveLiveConfigPath } from './lib/impeccable-paths.mjs';
import {
applySvelteKitLiveAdapter,
detectSvelteKitProject,
removeSvelteKitLiveAdapter,
-} from './live-sveltekit-adapter.mjs';
+} from './live/sveltekit-adapter.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = resolveLiveConfigPath({ cwd: process.cwd(), scriptsDir: __dirname });
diff --git a/plugin/skills/impeccable/scripts/live-insert.mjs b/plugin/skills/impeccable/scripts/live-insert.mjs
index 0658e9914..0ed3cafea 100644
--- a/plugin/skills/impeccable/scripts/live-insert.mjs
+++ b/plugin/skills/impeccable/scripts/live-insert.mjs
@@ -9,7 +9,7 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
import {
buildSearchQueries,
findElement,
@@ -25,7 +25,7 @@ import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentInsertSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const INSERT_POSITIONS = new Set(['before', 'after']);
diff --git a/plugin/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/plugin/skills/impeccable/scripts/live-manual-edit-evidence.mjs
index 860278b73..dd10e96dc 100644
--- a/plugin/skills/impeccable/scripts/live-manual-edit-evidence.mjs
+++ b/plugin/skills/impeccable/scripts/live-manual-edit-evidence.mjs
@@ -10,8 +10,8 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer, getBufferPath } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs';
const EVIDENCE_VERSION = 1;
const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
diff --git a/plugin/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/plugin/skills/impeccable/scripts/live-manual-edits-buffer.mjs
deleted file mode 100644
index 9e3dcf455..000000000
--- a/plugin/skills/impeccable/scripts/live-manual-edits-buffer.mjs
+++ /dev/null
@@ -1,152 +0,0 @@
-/**
- * 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 './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;
-}
diff --git a/plugin/skills/impeccable/scripts/live-poll.mjs b/plugin/skills/impeccable/scripts/live-poll.mjs
index 1e1259fbc..b031055fe 100644
--- a/plugin/skills/impeccable/scripts/live-poll.mjs
+++ b/plugin/skills/impeccable/scripts/live-poll.mjs
@@ -12,8 +12,8 @@
import { execFileSync } from 'node:child_process';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live-completion.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { completionAckForAcceptResult, completionTypeForAcceptResult } from './live/completion.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
// Node's built-in fetch (undici under the hood) enforces a 300s headers
// timeout that can't be lowered per-request. We cap each request below
diff --git a/plugin/skills/impeccable/scripts/live-resume.mjs b/plugin/skills/impeccable/scripts/live-resume.mjs
index e54831f12..74284d48a 100644
--- a/plugin/skills/impeccable/scripts/live-resume.mjs
+++ b/plugin/skills/impeccable/scripts/live-resume.mjs
@@ -3,7 +3,7 @@
* Recover the next agent action from the durable live-session journal.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs
index d9e06f449..187af0ac1 100644
--- a/plugin/skills/impeccable/scripts/live-server.mjs
+++ b/plugin/skills/impeccable/scripts/live-server.mjs
@@ -20,11 +20,11 @@ import fs from 'node:fs';
import path from 'node:path';
import net from 'node:net';
import { fileURLToPath } from 'node:url';
-import { parseDesignMd } from './design-parser.mjs';
+import { parseDesignMd } from './lib/design-parser.mjs';
import { resolveContextDir } from './context.mjs';
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { validateEvent } from './live-event-validation.mjs';
-import { LIVE_COMMANDS } from './live-vocabulary.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { validateEvent } from './live/event-validation.mjs';
+import { LIVE_COMMANDS } from './live/vocabulary.mjs';
import {
getDesignSidecarPath,
getLiveDir,
@@ -33,20 +33,20 @@ import {
removeLiveServerInfo,
resolveDesignSidecarPath,
writeLiveServerInfo,
-} from './impeccable-paths.mjs';
+} from './lib/impeccable-paths.mjs';
import {
countByPage as countPendingByPage,
readBuffer as readManualEditsBuffer,
removeEntries as removeManualEditEntries,
stageEntry as stageManualEditEntry,
truncateBuffer as truncateManualEditsBuffer,
-} from './live-manual-edits-buffer.mjs';
+} from './live/manual-edits-buffer.mjs';
import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
import { commitManualEdits } from './live-commit-manual-edits.mjs';
import {
applyDeferredSvelteComponentAccepts,
removeAllSvelteComponentSessions,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
diff --git a/plugin/skills/impeccable/scripts/live-session-store.mjs b/plugin/skills/impeccable/scripts/live-session-store.mjs
deleted file mode 100644
index 5ec4d34d4..000000000
--- a/plugin/skills/impeccable/scripts/live-session-store.mjs
+++ /dev/null
@@ -1,289 +0,0 @@
-import fs from 'node:fs';
-import path from 'node:path';
-import { getLegacyLiveSessionsDir, getLiveSessionsDir } from './impeccable-paths.mjs';
-
-const COMPLETED_PHASES = new Set(['completed', 'discarded']);
-
-export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
- const rootDir = getLiveSessionsDir(cwd);
- const legacyRootDir = getLegacyLiveSessionsDir(cwd);
- fs.mkdirSync(rootDir, { recursive: true });
- const snapshotCache = new Map();
-
- function loadCachedOrRebuild(id) {
- const cached = snapshotCache.get(id);
- if (cached) return cached;
- const journalPath = getReadableJournalPath(id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- return rebuilt;
- }
-
- 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;
- }
-
- return {
- rootDir,
- legacyRootDir,
- appendEvent(event) {
- const normalized = normalizeEvent(event, sessionId);
- const journalPath = getJournalPath(rootDir, normalized.id);
- const snapshotPath = getSnapshotPath(rootDir, normalized.id);
- const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
- if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
- fs.copyFileSync(legacyJournalPath, journalPath);
- }
- const prior = loadCachedOrRebuild(normalized.id);
- const seq = prior.nextSeq;
- const entry = {
- seq,
- 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, prior.diagnostics);
- snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
- writeSnapshot(snapshotPath, next);
- return next;
- },
- getSnapshot(id = sessionId, opts = {}) {
- if (!id) throw new Error('session id required');
- const journalPath = getReadableJournalPath(id);
- const snapshotPath = getSnapshotPath(rootDir, id);
- const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
- snapshotCache.set(id, rebuilt);
- writeSnapshot(snapshotPath, rebuilt.snapshot);
- if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
- return rebuilt.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));
- }
- }
- return [...ids]
- .sort()
- .map((id) => this.getSnapshot(id))
- .filter(Boolean);
- },
- };
-}
-
-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 safeSessionId(id) {
- if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
- return id;
-}
-
-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,
- activeOwner: null,
- sourceMarkers: {},
- fallbackMode: null,
- annotationArtifacts: [],
- diagnostics: [],
- updatedAt: 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, inheritedDiagnostics = []) {
- const event = entry.event || entry;
- const next = {
- ...snapshot,
- paramValues: { ...(snapshot.paramValues || {}) },
- sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
- annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
- diagnostics: [...(snapshot.diagnostics || [])],
- updatedAt: entry.ts || new Date().toISOString(),
- };
-
- if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
- next.diagnostics = [...inheritedDiagnostics];
- }
-
- 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);
- if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
- break;
- case 'variants_ready':
- case 'agent_done':
- next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
- 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.',
- });
- }
- break;
- case 'checkpoint':
- if (COMPLETED_PHASES.has(next.phase)) {
- next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
- break;
- }
- if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
- next.phase = event.phase ?? next.phase;
- next.checkpointRevision = event.revision ?? next.checkpointRevision;
- next.activeOwner = event.owner ?? next.activeOwner;
- next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
- next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
- next.sourceFile = event.sourceFile ?? next.sourceFile;
- next.previewFile = event.previewFile ?? next.previewFile;
- next.previewMode = event.previewMode ?? next.previewMode;
- if (event.paramValues) next.paramValues = { ...event.paramValues };
- } else {
- next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
- }
- break;
- case 'accept':
- case 'accept_intent':
- next.phase = 'accept_requested';
- 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 '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.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':
- 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) {
- fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
-}
diff --git a/plugin/skills/impeccable/scripts/live-status.mjs b/plugin/skills/impeccable/scripts/live-status.mjs
index a7009cc29..f7e464999 100644
--- a/plugin/skills/impeccable/scripts/live-status.mjs
+++ b/plugin/skills/impeccable/scripts/live-status.mjs
@@ -3,8 +3,8 @@
* Print durable recovery status for Impeccable live sessions.
*/
-import { createLiveSessionStore } from './live-session-store.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { createLiveSessionStore } from './live/session-store.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
import { manualApplyResumeHint } from './live-resume.mjs';
function readServerInfo() {
diff --git a/plugin/skills/impeccable/scripts/live-vocabulary.mjs b/plugin/skills/impeccable/scripts/live-vocabulary.mjs
deleted file mode 100644
index 1123d41c4..000000000
--- a/plugin/skills/impeccable/scripts/live-vocabulary.mjs
+++ /dev/null
@@ -1,36 +0,0 @@
-/**
- * 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: `` },
- { value: 'bolder', label: 'Bolder', icon: `` },
- { value: 'quieter', label: 'Quieter', icon: `` },
- { value: 'distill', label: 'Distill', icon: `` },
- { value: 'polish', label: 'Polish', icon: `` },
- { value: 'typeset', label: 'Typeset', icon: `` },
- { value: 'colorize', label: 'Colorize', icon: `` },
- { value: 'layout', label: 'Layout', icon: `` },
- { value: 'adapt', label: 'Adapt', icon: `` },
- { value: 'animate', label: 'Animate', icon: `` },
- { value: 'delight', label: 'Delight', icon: `` },
- { value: 'overdrive', label: 'Overdrive', icon: `` },
-];
-
-// Action values accepted by the live event protocol, in palette order.
-export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);
diff --git a/plugin/skills/impeccable/scripts/live-wrap.mjs b/plugin/skills/impeccable/scripts/live-wrap.mjs
index a83cbcc56..c3f00f3a4 100644
--- a/plugin/skills/impeccable/scripts/live-wrap.mjs
+++ b/plugin/skills/impeccable/scripts/live-wrap.mjs
@@ -13,13 +13,13 @@
import fs from 'node:fs';
import path from 'node:path';
-import { isGeneratedFile } from './is-generated.mjs';
-import { readBuffer as readManualEditsBuffer } from './live-manual-edits-buffer.mjs';
+import { isGeneratedFile } from './lib/is-generated.mjs';
+import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import {
buildSvelteComponentCssAuthoring,
scaffoldSvelteComponentSession,
shouldUseSvelteComponentInjection,
-} from './live-svelte-component.mjs';
+} from './live/svelte-component.mjs';
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
diff --git a/plugin/skills/impeccable/scripts/live.mjs b/plugin/skills/impeccable/scripts/live.mjs
index 8acd300ed..0992da1bd 100644
--- a/plugin/skills/impeccable/scripts/live.mjs
+++ b/plugin/skills/impeccable/scripts/live.mjs
@@ -23,7 +23,7 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { loadContext } from './context.mjs';
import { resolveFiles } from './live-inject.mjs';
-import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { readLiveServerInfo } from './lib/impeccable-paths.mjs';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
diff --git a/plugin/skills/impeccable/scripts/live-completion.mjs b/plugin/skills/impeccable/scripts/live/completion.mjs
similarity index 100%
rename from plugin/skills/impeccable/scripts/live-completion.mjs
rename to plugin/skills/impeccable/scripts/live/completion.mjs
diff --git a/plugin/skills/impeccable/scripts/live/event-validation.mjs b/plugin/skills/impeccable/scripts/live/event-validation.mjs
new file mode 100644
index 000000000..0897b1317
--- /dev/null
+++ b/plugin/skills/impeccable/scripts/live/event-validation.mjs
@@ -0,0 +1,137 @@
+/**
+ * Shared event validation for the live helper server.
+ * Extracted for unit testing (insert mode rules).
+ */
+
+import { canCreateInsert } from './insert-ui.mjs';
+
+// The accepted visual action values come from the canonical vocabulary so the
+// validator, the picker UI, and the marketing demo never drift. Imported (not
+// just re-exported) so it is also in scope for the validators below.
+import { VISUAL_ACTIONS } from './vocabulary.mjs';
+export { VISUAL_ACTIONS };
+
+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 = ['<', '{', '}', '`'];
+
+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;
+}
+
+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 '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;
+ default:
+ return 'Unknown event type: ' + msg.type;
+ }
+}
diff --git a/plugin/skills/impeccable/scripts/live-insert-ui.mjs b/plugin/skills/impeccable/scripts/live/insert-ui.mjs
similarity index 100%
rename from plugin/skills/impeccable/scripts/live-insert-ui.mjs
rename to plugin/skills/impeccable/scripts/live/insert-ui.mjs
diff --git a/plugin/skills/impeccable/scripts/live/manual-edits-buffer.mjs b/plugin/skills/impeccable/scripts/live/manual-edits-buffer.mjs
new file mode 100644
index 000000000..d96ebbe34
--- /dev/null
+++ b/plugin/skills/impeccable/scripts/live/manual-edits-buffer.mjs
@@ -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;
+}
diff --git a/plugin/skills/impeccable/scripts/live/session-store.mjs b/plugin/skills/impeccable/scripts/live/session-store.mjs
new file mode 100644
index 000000000..affba67c9
--- /dev/null
+++ b/plugin/skills/impeccable/scripts/live/session-store.mjs
@@ -0,0 +1,289 @@
+import fs from 'node:fs';
+import path from 'node:path';
+import { getLegacyLiveSessionsDir, getLiveSessionsDir } from '../lib/impeccable-paths.mjs';
+
+const COMPLETED_PHASES = new Set(['completed', 'discarded']);
+
+export function createLiveSessionStore({ cwd = process.cwd(), sessionId } = {}) {
+ const rootDir = getLiveSessionsDir(cwd);
+ const legacyRootDir = getLegacyLiveSessionsDir(cwd);
+ fs.mkdirSync(rootDir, { recursive: true });
+ const snapshotCache = new Map();
+
+ function loadCachedOrRebuild(id) {
+ const cached = snapshotCache.get(id);
+ if (cached) return cached;
+ const journalPath = getReadableJournalPath(id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ return rebuilt;
+ }
+
+ 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;
+ }
+
+ return {
+ rootDir,
+ legacyRootDir,
+ appendEvent(event) {
+ const normalized = normalizeEvent(event, sessionId);
+ const journalPath = getJournalPath(rootDir, normalized.id);
+ const snapshotPath = getSnapshotPath(rootDir, normalized.id);
+ const legacyJournalPath = getJournalPath(legacyRootDir, normalized.id);
+ if (!fs.existsSync(journalPath) && fs.existsSync(legacyJournalPath)) {
+ fs.copyFileSync(legacyJournalPath, journalPath);
+ }
+ const prior = loadCachedOrRebuild(normalized.id);
+ const seq = prior.nextSeq;
+ const entry = {
+ seq,
+ 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, prior.diagnostics);
+ snapshotCache.set(normalized.id, { snapshot: next, diagnostics: next.diagnostics || [], nextSeq: seq + 1 });
+ writeSnapshot(snapshotPath, next);
+ return next;
+ },
+ getSnapshot(id = sessionId, opts = {}) {
+ if (!id) throw new Error('session id required');
+ const journalPath = getReadableJournalPath(id);
+ const snapshotPath = getSnapshotPath(rootDir, id);
+ const rebuilt = rebuildSnapshotFromJournal(journalPath, id);
+ snapshotCache.set(id, rebuilt);
+ writeSnapshot(snapshotPath, rebuilt.snapshot);
+ if (!opts.includeCompleted && COMPLETED_PHASES.has(rebuilt.snapshot.phase)) return null;
+ return rebuilt.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));
+ }
+ }
+ return [...ids]
+ .sort()
+ .map((id) => this.getSnapshot(id))
+ .filter(Boolean);
+ },
+ };
+}
+
+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 safeSessionId(id) {
+ if (!/^[A-Za-z0-9_-]{1,128}$/.test(id)) throw new Error('invalid session id: ' + id);
+ return id;
+}
+
+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,
+ activeOwner: null,
+ sourceMarkers: {},
+ fallbackMode: null,
+ annotationArtifacts: [],
+ diagnostics: [],
+ updatedAt: 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, inheritedDiagnostics = []) {
+ const event = entry.event || entry;
+ const next = {
+ ...snapshot,
+ paramValues: { ...(snapshot.paramValues || {}) },
+ sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
+ annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
+ diagnostics: [...(snapshot.diagnostics || [])],
+ updatedAt: entry.ts || new Date().toISOString(),
+ };
+
+ if (inheritedDiagnostics.length && next.diagnostics.length === 0) {
+ next.diagnostics = [...inheritedDiagnostics];
+ }
+
+ 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);
+ if (event.screenshotPath) upsertArtifact(next.annotationArtifacts, { type: 'screenshot', path: event.screenshotPath });
+ break;
+ case 'variants_ready':
+ case 'agent_done':
+ next.phase = event.carbonize === true ? 'carbonize_required' : 'variants_ready';
+ 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.',
+ });
+ }
+ break;
+ case 'checkpoint':
+ if (COMPLETED_PHASES.has(next.phase)) {
+ next.diagnostics.push({ error: 'checkpoint_after_terminal_ignored', phase: event.phase ?? null, revision: event.revision ?? null });
+ break;
+ }
+ if ((event.revision ?? 0) >= (next.checkpointRevision ?? 0)) {
+ next.phase = event.phase ?? next.phase;
+ next.checkpointRevision = event.revision ?? next.checkpointRevision;
+ next.activeOwner = event.owner ?? next.activeOwner;
+ next.arrivedVariants = event.arrivedVariants ?? next.arrivedVariants;
+ next.visibleVariant = event.visibleVariant ?? next.visibleVariant;
+ next.sourceFile = event.sourceFile ?? next.sourceFile;
+ next.previewFile = event.previewFile ?? next.previewFile;
+ next.previewMode = event.previewMode ?? next.previewMode;
+ if (event.paramValues) next.paramValues = { ...event.paramValues };
+ } else {
+ next.diagnostics.push({ error: 'stale_checkpoint_ignored', revision: event.revision });
+ }
+ break;
+ case 'accept':
+ case 'accept_intent':
+ next.phase = 'accept_requested';
+ 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 '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.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':
+ 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) {
+ fs.writeFileSync(snapshotPath, JSON.stringify(snapshot, null, 2) + '\n');
+}
diff --git a/plugin/skills/impeccable/scripts/live-svelte-component.mjs b/plugin/skills/impeccable/scripts/live/svelte-component.mjs
similarity index 100%
rename from plugin/skills/impeccable/scripts/live-svelte-component.mjs
rename to plugin/skills/impeccable/scripts/live/svelte-component.mjs
diff --git a/plugin/skills/impeccable/scripts/live-sveltekit-adapter.mjs b/plugin/skills/impeccable/scripts/live/sveltekit-adapter.mjs
similarity index 100%
rename from plugin/skills/impeccable/scripts/live-sveltekit-adapter.mjs
rename to plugin/skills/impeccable/scripts/live/sveltekit-adapter.mjs
diff --git a/plugin/skills/impeccable/scripts/live-ui-core.mjs b/plugin/skills/impeccable/scripts/live/ui-core.mjs
similarity index 100%
rename from plugin/skills/impeccable/scripts/live-ui-core.mjs
rename to plugin/skills/impeccable/scripts/live/ui-core.mjs
diff --git a/plugin/skills/impeccable/scripts/live/vocabulary.mjs b/plugin/skills/impeccable/scripts/live/vocabulary.mjs
new file mode 100644
index 000000000..5c7b0b713
--- /dev/null
+++ b/plugin/skills/impeccable/scripts/live/vocabulary.mjs
@@ -0,0 +1,36 @@
+/**
+ * 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: `` },
+ { value: 'bolder', label: 'Bolder', icon: `` },
+ { value: 'quieter', label: 'Quieter', icon: `` },
+ { value: 'distill', label: 'Distill', icon: `` },
+ { value: 'polish', label: 'Polish', icon: `` },
+ { value: 'typeset', label: 'Typeset', icon: `` },
+ { value: 'colorize', label: 'Colorize', icon: `` },
+ { value: 'layout', label: 'Layout', icon: `` },
+ { value: 'adapt', label: 'Adapt', icon: `` },
+ { value: 'animate', label: 'Animate', icon: `` },
+ { value: 'delight', label: 'Delight', icon: `` },
+ { value: 'overdrive', label: 'Overdrive', icon: `` },
+];
+
+// Action values accepted by the live event protocol, in palette order.
+export const VISUAL_ACTIONS = LIVE_COMMANDS.map((c) => c.value);