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);