commit a619938e0c4c095e46ecc57fe62ad6b8e4b859db
Author: 王冕
Date: Tue Jun 30 15:32:30 2026 +0800
Initial commit: 小羚羚小程序 Axhub Make workspace.
Include xll-miniapp prototype, PRD resources, annotation directory sync, agent skills, and cloud publishing setup.
Co-authored-by: Cursor
diff --git a/.agents/skills/canvas-workspace/SKILL.md b/.agents/skills/canvas-workspace/SKILL.md
new file mode 100644
index 0000000..ad5272c
--- /dev/null
+++ b/.agents/skills/canvas-workspace/SKILL.md
@@ -0,0 +1,48 @@
+---
+name: canvas-workspace
+description: 当任务涉及 Axhub 画布、Excalidraw 文件、画布节点、批注、截图、画布图片、原型/文档/主题嵌入节点或 AI 生成节点时使用。
+---
+
+# Canvas Workspace — 画布工作区
+
+当任务涉及 Axhub 画布时使用本技能。每个原型拥有自己的 Excalidraw 画布文件:
+
+```text
+src/prototypes//canvas.excalidraw
+src/prototypes//canvas-assets/
+```
+
+本技能用于按 Axhub Make 约定读取和写入画布,重点关注 `customData`、嵌入资源节点、批注、图片文件和 AI 生成节点。
+
+## 读取顺序
+
+1. 用户指定画布名或画布链接时,先从名称或链接定位对应的 `canvas.excalidraw`。
+2. 查看 `elements`、`files` 和元素的 `customData`。
+3. 只有元素引用了持久化截图或图片文件时,才读取 `canvas-assets/`。
+4. CLI 用于获取当前浏览器会话信息或截图。
+
+## 参考文档
+
+- 文件路径、读写规则、CLI 命令和关系检查:`references/canvas-read-write.md`
+- Axhub 专属节点和 `customData` 字段:`references/axhub-nodes.md`
+- Excalidraw 图形结构与布局基础:`references/excalidraw-basics.md`
+- JSON 元素结构模板:`references/element-templates.md`
+
+## 默认规则
+
+- 优先直接编辑 `.excalidraw` JSON。
+- 元素 `id` 必须唯一,并尽量沿用现有文件的 ID 风格。
+- 修改元素时同步更新 `version`、`versionNonce` 和 `updated`。
+- 结构性改动后检查绑定、容器、分组和 Frame 引用。
+- 除非用户需求要求修改,否则保留已有 Axhub `customData`。
+- 创建或替换 prototype 预览节点时,画布上的节点尺寸与网页内部视口要分开处理:节点可以用较小可视尺寸避免占满画布,但网页仍按真实浏览器尺寸设计,通过 `customData.embedContentScale` 缩放显示。
+
+## 回复要求
+
+完成画布相关工作后,说明:
+
+- 画布文件路径。
+- 修改了什么,或读取到了什么。
+- 相关节点 ID 或批注。
+- 是否使用了本地图片或 `canvas-assets/`。
+- 如果当前环境能确定,给出画布确认链接。
diff --git a/.agents/skills/canvas-workspace/agents/openai.yaml b/.agents/skills/canvas-workspace/agents/openai.yaml
new file mode 100644
index 0000000..e1913de
--- /dev/null
+++ b/.agents/skills/canvas-workspace/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "画布工作区"
+ short_description: "在 Axhub 画布上绘图、构思方案、整理节点,读取批注、截图或图片"
+ default_prompt: "使用 $canvas-workspace 读取或整理 Axhub 画布内容。"
diff --git a/.agents/skills/canvas-workspace/references/axhub-nodes.md b/.agents/skills/canvas-workspace/references/axhub-nodes.md
new file mode 100644
index 0000000..95fd3ca
--- /dev/null
+++ b/.agents/skills/canvas-workspace/references/axhub-nodes.md
@@ -0,0 +1,155 @@
+# Axhub 画布节点
+
+Axhub 画布节点本质上是标准 Excalidraw 元素,Axhub 扩展信息存放在 `customData` 中。
+
+## 通用字段
+
+| 字段 | 含义 |
+| --- | --- |
+| `customData.title` | 面向用户的节点标题 |
+| `customData.previewUrl` | 预览模式中渲染的 URL |
+| `customData.openUrl` | 节点操作中打开的 URL |
+| `customData.previewKind` | 渲染类型,例如 `web`、`doc`、`image`、`none`、`ai-image-generator`、`prototype-generator` |
+| `customData.resourceType` | 资源类型:`prototype`、`doc` 或 `theme` |
+| `customData.resourceId` | 项目 metadata 中的资源 id 或名称 |
+| `customData.embedViewMode` | `link` 表示紧凑链接卡片,`preview` 表示渲染嵌入预览 |
+| `customData.embedContentScale` | 预览内容缩放比例,例如 `0.5` 表示画布节点按 50% 显示,但 iframe 使用 2 倍视口渲染 |
+| `customData.screenshotUrl` | 运行时已捕获的持久化预览截图 URL |
+| `customData.annotation` | 元素批注文本 |
+| `customData.annotationUpdatedAt` | 批注更新时间,ISO 8601 格式 |
+
+## 嵌入资源节点
+
+嵌入资源使用 `type: "embeddable"`。
+
+### 原型节点
+
+通过 `customData.resourceType: "prototype"` 识别;也可以结合指向原型的 `link` 或 `previewUrl` 判断。
+
+常见字段:
+
+```json
+{
+ "type": "embeddable",
+ "link": "/?resourceType=prototype&resourceId=&view=demo&sidebar=collapsed",
+ "customData": {
+ "title": "原型标题",
+ "previewUrl": "http://localhost:/prototypes/",
+ "openUrl": "/?resourceType=prototype&resourceId=&view=demo&sidebar=collapsed",
+ "previewKind": "web",
+ "resourceType": "prototype",
+ "resourceId": "",
+ "embedViewMode": "link"
+ }
+}
+```
+
+由 AI 原型生成能力产出的原型节点还可能包含:
+
+```json
+{
+ "generatedBy": "axhub-prototype-generator",
+ "sourceTaskId": "",
+ "prompt": ""
+}
+```
+
+### 文档节点
+
+通过 `customData.type: "axhub-doc"` 或 `customData.resourceType: "doc"` 识别。
+
+常见字段:
+
+```json
+{
+ "type": "embeddable",
+ "link": "/api/markdown-file?path=",
+ "customData": {
+ "type": "axhub-doc",
+ "title": "文档标题",
+ "previewUrl": "/api/markdown-file?path=",
+ "previewKind": "doc",
+ "resourceType": "doc",
+ "resourceId": "",
+ "embedViewMode": "link"
+ }
+}
+```
+
+### 主题节点
+
+通过 `customData.resourceType: "theme"` 或 `customData.type: "axhub-theme"` 识别。
+
+主题节点与原型/文档节点使用相同的 `embeddable` 结构,`resourceType` 为 `theme`,`previewKind` 通常为 `web` 或 `none`。
+
+## AI 生成节点
+
+AI 生成节点是图片元素。占位图或生成图片数据保存在 `files[fileId]`。
+
+### AI 图片生成节点
+
+```json
+{
+ "type": "image",
+ "fileId": "axhub-ai-image-placeholder-v2",
+ "customData": {
+ "type": "axhub-ai-image-generator",
+ "title": "AI 生成图片",
+ "previewKind": "ai-image-generator"
+ }
+}
+```
+
+### AI 图片结果节点
+
+```json
+{
+ "type": "image",
+ "fileId": "",
+ "customData": {
+ "type": "axhub-ai-image",
+ "generatedBy": "axhub-ai-image",
+ "sourceTaskId": "",
+ "prompt": "",
+ "previewKind": "image"
+ }
+}
+```
+
+多张生成图片可能共享同一个 `groupIds` 值。
+
+### AI 原型生成节点
+
+```json
+{
+ "type": "image",
+ "fileId": "axhub-prototype-generator-placeholder-v1",
+ "customData": {
+ "type": "axhub-prototype-generator",
+ "title": "AI 生成原型",
+ "previewKind": "prototype-generator"
+ }
+}
+```
+
+生成完成后,占位节点会被替换为原型嵌入节点,并带有 `generatedBy: "axhub-prototype-generator"`。
+
+AI 生成原型替换节点的推荐尺寸:
+
+- 不要把网页内部布局做小;页面代码仍按正常浏览器视口设计。
+- `previewUrl`、`openUrl`、`link` 使用客户端原型运行时地址,例如 `/prototypes/` 或带 hash/page 的同源 runtime URL;不要使用 Make 管理端首页 deep link,例如 `/?p=...` 或 `/?resourceType=prototype...`。
+- 为了避免画布被完整桌面尺寸占满,推荐生成节点可视尺寸为 `720 x 450`。
+- 同时设置 `customData.embedSizePreset: "desktop"`、`customData.embedContentScale: 0.5`、`customData.storedPreviewSize: { "width": 720, "height": 450 }`。这样画布显示为 720x450,iframe 与截图按 1440x900 视口渲染。
+- 新生成的 prototype embeddable 可设置 `customData.captureScreenshotOnMount: true`,让宿主首次渲染后自动捕获预览截图。截图成功后宿主会清除此字段并写入 `screenshotUrl`,不要手写 `screenshotUrl`。
+
+## 图片文件
+
+图片元素通过 `files[element.fileId]` 读取图片数据。
+
+原型嵌入节点如果存在 `customData.screenshotUrl`,优先使用该截图地址。截图文件常见位置:
+
+```text
+src/prototypes//canvas-assets/embed-.png
+```
+
+截图缓存不等同于页面实现素材;只有用户明确要求把它作为素材使用时,才把它当作实现资产处理。
diff --git a/.agents/skills/canvas-workspace/references/canvas-read-write.md b/.agents/skills/canvas-workspace/references/canvas-read-write.md
new file mode 100644
index 0000000..cbb957a
--- /dev/null
+++ b/.agents/skills/canvas-workspace/references/canvas-read-write.md
@@ -0,0 +1,122 @@
+# 画布读写能力参考
+
+面向使用 Skill 的 Agent:优先读写本地 `.excalidraw` 文件。用户指定画布名或画布链接时,直接定位本地画布文件;CLI 用于获取当前浏览器会话信息或截图。
+
+## 快速判断
+
+| 目标 | 优先方式 |
+|------|----------|
+| 读取画布元素、批注、节点信息 | 直接读 `.excalidraw` |
+| 修改画布内容 | 直接改 `.excalidraw` |
+| 从用户给的画布链接定位元素 | 从链接提取画布名和元素 ID,再读文件 |
+| 获取当前浏览器里画布的截图 | `axhub-make canvas screenshot` |
+| 查看当前浏览器连接了哪些画布 | `axhub-make canvas info` |
+
+## 文件位置
+
+常见路径:
+
+```text
+src/prototypes//canvas.excalidraw
+src/prototypes//canvas-assets/embed-.png
+```
+
+`.excalidraw` 是 JSON。主要关注:
+
+- `elements`:所有画布元素。
+- `files`:嵌入图片数据或图片元信息。
+- `appState.gridSize`:整理/对齐时参考。
+
+## 读取画布
+
+读取过程以本地 JSON 为准。
+
+最常用字段:
+
+| 字段 | 用途 |
+|------|------|
+| `id` | 元素唯一标识,链接定位和截图文件名会用到 |
+| `type` | 元素类型,如 `text`、`image`、`embeddable`、`arrow` |
+| `x` / `y` / `width` / `height` | 位置和尺寸 |
+| `isDeleted` | 为 true 时跳过 |
+| `link` | 嵌入节点链接 |
+| `customData` | 批注、标题、截图地址等 Axhub 扩展信息 |
+| `fileId` | 图片元素对应的 `files[fileId]` |
+
+识别常见节点:
+
+| 类型 | 判断方式 |
+|------|----------|
+| 原型节点 | `type == "embeddable"` 且 `customData.resourceType == "prototype"`,或 `link`/`previewUrl` 指向原型 |
+| 文档节点 | `type == "embeddable"` 且 `customData.type == "axhub-doc"` 或 `customData.resourceType == "doc"` |
+| 主题节点 | `type == "embeddable"` 且 `customData.resourceType == "theme"` 或 `customData.type == "axhub-theme"` |
+| AI 图片生成节点 | `type == "image"` 且 `customData.type == "axhub-ai-image-generator"` |
+| AI 图片结果节点 | `type == "image"` 且 `customData.type == "axhub-ai-image"` |
+| AI 原型生成节点 | `type == "image"` 且 `customData.type == "axhub-prototype-generator"` |
+| 图片元素 | `type == "image"` |
+| 批注元素 | `customData.annotation` 有值 |
+
+Axhub 节点字段见 `axhub-nodes.md`。
+
+## CLI 读取
+
+CLI 面向当前浏览器会话。读取元素、节点和批注时仍以 `.excalidraw` 文件为准。
+
+查看当前浏览器连接的画布:
+
+```bash
+axhub-make canvas info
+```
+
+获取当前画布截图:
+
+```bash
+axhub-make canvas screenshot -o ./canvas.png
+axhub-make canvas screenshot -c prototypes/my-proto/canvas -o ./canvas.png
+```
+
+## 从链接定位
+
+用户可能给一个带节点 ID 的画布链接。处理步骤:
+
+1. 从 URL 中提取画布名和元素 ID。
+2. 找到对应 `.excalidraw` 文件。
+3. 在 `elements` 中找同 ID 元素。
+4. 如果是原型节点,预览截图通常在 `canvas-assets/embed-.png`。
+5. 如果是图片元素,按 `fileId` 找 `files[fileId]`。
+6. 如果是嵌入节点,结合 `customData.resourceType`、`customData.previewUrl` 和 `customData.screenshotUrl` 判断资源来源。
+
+## 写入画布
+
+直接修改 `.excalidraw` 的 `elements` 数组。
+
+### 添加元素
+
+追加到 `elements`。必须有唯一 `id`,推荐沿用现有格式:`-`。
+
+### 修改元素
+
+更新目标字段后,同时更新:
+
+- `version` 加 1
+- `versionNonce` 换成新的随机整数
+- `updated` 设为当前毫秒时间戳
+
+### 删除元素
+
+默认直接从 `elements` 中移除。不要为了删除而设置 `isDeleted: true`,这会让画布文件持续膨胀。
+
+如果遇到历史遗留的 `isDeleted: true` 元素,读取时跳过;整理画布时可以一并移除。
+
+### 关系检查
+
+修改连接、容器、分组时检查引用是否仍然存在:
+
+- `boundElements`
+- `containerId`
+- `startBinding` / `endBinding`
+- `groupIds`
+
+## 热更新
+
+保存 `.excalidraw` 后,打开中的画布会通过热更新同步。完成画布写入时,交付说明里标明画布文件路径和改动内容。
diff --git a/.agents/skills/canvas-workspace/references/element-templates.md b/.agents/skills/canvas-workspace/references/element-templates.md
new file mode 100644
index 0000000..3f04ea5
--- /dev/null
+++ b/.agents/skills/canvas-workspace/references/element-templates.md
@@ -0,0 +1,234 @@
+# Excalidraw 元素模板
+
+只记录 Agent 写画布时最常用的最小字段。不要复制过大的完整元素样例;优先沿用现有画布里的同类元素字段,再按这里补关键字段。
+
+## 通用规则
+
+- 每个元素必须有唯一 `id`,推荐 `-`。
+- 新元素设置 `version: 1`、新的 `versionNonce`、当前毫秒 `updated`。
+- 默认 `roughness: 0`、`opacity: 100`、`strokeStyle: "solid"`。
+- 删除元素时直接从 `elements` 移除,不新写 `isDeleted: true`。
+- 修改绑定、容器、分组时同步检查引用是否存在。
+
+## 基础形状
+
+矩形、椭圆、菱形共用这类结构:
+
+```json
+{
+ "id": "",
+ "type": "rectangle",
+ "x": 100,
+ "y": 100,
+ "width": 200,
+ "height": 100,
+ "strokeColor": "#1e1e1e",
+ "backgroundColor": "#a5d8ff",
+ "fillStyle": "solid",
+ "strokeWidth": 2,
+ "strokeStyle": "solid",
+ "roughness": 0,
+ "opacity": 100,
+ "roundness": { "type": 3 },
+ "seed": 100001,
+ "version": 1,
+ "versionNonce": 1234567890,
+ "updated": 1778336862857,
+ "groupIds": [],
+ "frameId": null,
+ "boundElements": null,
+ "link": null,
+ "locked": false
+}
+```
+
+把 `type` 改为 `ellipse` 或 `diamond` 即可。圆角:直角用 `roundness: null`,圆角用 `{ "type": 3 }`。
+
+## 文本
+
+```json
+{
+ "id": "",
+ "type": "text",
+ "x": 100,
+ "y": 100,
+ "width": 220,
+ "height": 28,
+ "text": "标题",
+ "originalText": "标题",
+ "fontSize": 20,
+ "fontFamily": 3,
+ "textAlign": "left",
+ "verticalAlign": "top",
+ "containerId": null,
+ "autoResize": true,
+ "lineHeight": 1.25,
+ "strokeColor": "#1e1e1e",
+ "backgroundColor": "transparent",
+ "roughness": 0,
+ "opacity": 100,
+ "seed": 100002,
+ "version": 1,
+ "versionNonce": 1234567890,
+ "updated": 1778336862857,
+ "groupIds": [],
+ "frameId": null
+}
+```
+
+`text` 和 `originalText` 保持一致,且只放纯文本。
+
+## 箭头与线
+
+```json
+{
+ "id": "",
+ "type": "arrow",
+ "x": 300,
+ "y": 150,
+ "width": 200,
+ "height": 0,
+ "points": [[0, 0], [200, 0]],
+ "startBinding": null,
+ "endBinding": null,
+ "startArrowhead": null,
+ "endArrowhead": "arrow",
+ "strokeColor": "#1e1e1e",
+ "strokeWidth": 2,
+ "roughness": 0,
+ "opacity": 100,
+ "seed": 100003,
+ "version": 1,
+ "versionNonce": 1234567890,
+ "updated": 1778336862857,
+ "groupIds": [],
+ "frameId": null
+}
+```
+
+线条用 `type: "line"`,并把 `endArrowhead` 设为 `null`。
+
+绑定到元素时:
+
+```json
+{
+ "startBinding": { "elementId": "", "focus": 0, "gap": 1 },
+ "endBinding": { "elementId": "", "focus": 0, "gap": 1 }
+}
+```
+
+同时在被绑定元素的 `boundElements` 中添加箭头引用。
+
+## 原型节点
+
+```json
+{
+ "id": "",
+ "type": "embeddable",
+ "x": 100,
+ "y": 100,
+ "width": 1440,
+ "height": 900,
+ "link": "/?resourceType=prototype&resourceId=&view=demo&sidebar=collapsed",
+ "customData": {
+ "title": "原型标题",
+ "previewUrl": "http://localhost:/prototypes/",
+ "openUrl": "/?resourceType=prototype&resourceId=&view=demo&sidebar=collapsed",
+ "previewKind": "web",
+ "resourceType": "prototype",
+ "resourceId": "",
+ "embedViewMode": "link",
+ "embedSizePreset": "desktop"
+ },
+ "strokeColor": "#1e1e1e",
+ "backgroundColor": "transparent",
+ "roughness": 0,
+ "opacity": 100,
+ "roundness": { "type": 3 },
+ "seed": 200001,
+ "version": 1,
+ "versionNonce": 1234567890,
+ "updated": 1778336862857,
+ "groupIds": [],
+ "frameId": null,
+ "boundElements": null
+}
+```
+
+截图字段由运行时维护。不要手写 `screenshotUrl`,除非正在修复已有截图缓存。
+
+## 文档节点
+
+```json
+{
+ "id": "",
+ "type": "embeddable",
+ "x": 500,
+ "y": 100,
+ "width": 420,
+ "height": 640,
+ "link": "/api/markdown-file?path=",
+ "customData": {
+ "type": "axhub-doc",
+ "title": "文档标题",
+ "previewUrl": "/api/markdown-file?path=",
+ "previewKind": "doc",
+ "resourceType": "doc",
+ "resourceId": "",
+ "embedViewMode": "link"
+ },
+ "strokeColor": "#1e1e1e",
+ "backgroundColor": "transparent",
+ "roughness": 0,
+ "opacity": 100,
+ "roundness": { "type": 3 },
+ "seed": 300001,
+ "version": 1,
+ "versionNonce": 1234567890,
+ "updated": 1778336862857,
+ "groupIds": [],
+ "frameId": null,
+ "boundElements": null
+}
+```
+
+`path` 参数必须 URL 编码。
+
+## Frame
+
+```json
+{
+ "id": "",
+ "type": "frame",
+ "x": 80,
+ "y": 80,
+ "width": 600,
+ "height": 400,
+ "name": "功能模块",
+ "strokeColor": "#bbb",
+ "backgroundColor": "transparent",
+ "roughness": 0,
+ "opacity": 100,
+ "seed": 400001,
+ "version": 1,
+ "versionNonce": 1234567890,
+ "updated": 1778336862857,
+ "groupIds": [],
+ "frameId": null
+}
+```
+
+子元素通过 `frameId` 引用 frame 的 `id`。
+
+## 批注
+
+给任意元素补:
+
+```json
+{
+ "customData": {
+ "annotation": "这里需要确认",
+ "annotationUpdatedAt": "2026-05-11T09:00:00.000Z"
+ }
+}
+```
diff --git a/.agents/skills/canvas-workspace/references/excalidraw-basics.md b/.agents/skills/canvas-workspace/references/excalidraw-basics.md
new file mode 100644
index 0000000..8d093ad
--- /dev/null
+++ b/.agents/skills/canvas-workspace/references/excalidraw-basics.md
@@ -0,0 +1,49 @@
+# Excalidraw 基础指导
+
+这份只说明如何把内容组织成 Excalidraw 图,不规定 Axhub 画布流程。基础思路参考 `excalidraw-diagram-generator`:先判断图类型,再抽取元素、关系和复杂度,最后生成清晰布局。
+
+参考来源:https://www.skills.sh/github/awesome-copilot/excalidraw-diagram-generator
+
+## 先判断图类型
+
+| 用户意图 | 适合图形 |
+|----------|----------|
+| 流程、步骤、审批、状态流转 | 流程图 |
+| 模块关系、依赖、信息结构 | 关系图 |
+| 概念拆解、头脑风暴 | 思维导图 |
+| 系统、页面、组件、服务结构 | 架构图 |
+| 数据输入、处理、输出 | 数据流图 |
+| 多角色协作流程 | 泳道图 |
+
+如果画布内容涉及真实图片、UI 设计稿或素材,先按用户目标确认它是画布参考、画布节点,还是项目实现素材。
+
+## 抽取信息
+
+动手画之前先明确:
+
+- 关键元素:节点、步骤、角色、页面、模块。
+- 关系:先后、依赖、父子、输入输出、跳转。
+- 复杂度:元素太多时拆成多个 Frame 或多张图。
+- 主次:优先画主路径,细节放到旁边补充。
+
+## 布局原则
+
+- 流程图:从左到右或从上到下。
+- 关系图:核心元素居中,相关元素围绕或分组。
+- 架构图:按层级、职责或数据流分区。
+- 思维导图:中心主题 + 4-6 个主分支。
+- 泳道图:角色用列或行,活动放进对应泳道。
+
+相关内容使用 Frame 分组,组内元素用 `frameId` 归属到对应 Frame。
+
+## 控制复杂度
+
+- 单张图优先控制在 20 个核心元素以内。
+- 复杂请求先画高层图,再按模块拆详细图。
+- 避免把长文档逐句搬进画布;画结构和决策点。
+- 颜色只表达语义,不做装饰。
+
+## 与 Axhub 规范的边界
+
+- 画布文件仍写入 `src/prototypes//canvas.excalidraw`。
+- 字体、颜色、节点、资源和交付口径仍按本技能主文档与其他 references 执行。
diff --git a/.agents/skills/explore-options/SKILL.md b/.agents/skills/explore-options/SKILL.md
new file mode 100644
index 0000000..288f218
--- /dev/null
+++ b/.agents/skills/explore-options/SKILL.md
@@ -0,0 +1,99 @@
+---
+name: explore-options
+description: Use when a Make client批注 or user request asks for 多方案探索, 多方案生成, 多方案对比, 方案对比, 设计决策, 比稿, 先出方案, or choosing among 2-3 UI/code modification directions before executing one.
+---
+
+# 多方案探索
+
+用于处理批注或需求里的多方案探索、生成和对比。目标是延续 Make client 的需求对齐和设计决策口径:先围绕当前问题发散出真实不同的设计方向,再通过方案对比收敛为一个可执行的设计决策。
+
+## 流程
+
+1. 先锁定共同约束:产品需求、当前页面目标、用户明确要求、不可改内容、设计基底和验收重点。
+2. 先发散:没有指定数量时默认给 3 个方案;用户指定数量时严格按用户要求。
+3. 方案差异必须来自页面骨架、信息组织、交互路径、内容密度、视觉反馈、动效状态或内容表达,不只换色换皮。
+4. 方案梯度建议覆盖:稳健继承现有模式、平衡优化当前体验、突破探索新表达。不要让多个方案只是同一想法的轻微改写。
+5. 所有方案都要遵守已确认的需求和设计决策,不能把锁定项当差异点。
+6. 再收敛:比较方案适合的用户任务、实现成本、设计一致性、风险和可验证性,形成一个设计决策。
+7. 执行最适合当前页面的一种;只有会改变需求范围、设计基底、核心流程或用户明确要求选择时,才停下来确认。
+
+## 输出
+
+- 多方案探索:每个方案一句话说明,体现真实差异。
+- 差异说明:覆盖骨架、信息/交互、视觉/反馈、动效/状态和主要风险。
+- 设计决策:写明采用哪个方案、为什么淘汰其他方案,以及取舍理由。
+- 执行结果:列出改了什么和如何验收。
+
+## 方案切换落地
+
+如果用户要求“看看不同方向”“能切换比较”,或当前实现适合在页面内切换方案,就把方案做成 tweak:
+
+- React 原型优先使用 `axhub-genie-editor-react` 的 `createGenieEditorReactTweakStore` 和 `useRegisterGenieEditorTweak`。
+- 复用项目现有 `schema / values / adapter / update` 模式,不另造平行配置。
+- 方案字段优先用 `card`,不要用普通下拉。
+- 每个 `options[]` 项至少包含 `label`、`description`、`value`。
+- `label` 写方案标题,`description` 写核心差异,`value` 写稳定标识。
+- 用户要求页面内切换时,必须落成 React tweak;否则只需要完成多方案探索、方案对比、设计决策和最终方案实现。
+
+常用字段类型:
+
+| type | 适用场景 |
+| --- | --- |
+| `card` | 多方案对比、布局方向、风格方向等带标题和描述的单选方案 |
+| `select` | 简单枚举,例如尺寸、密度、variant |
+| `switch` | 显示/隐藏、启用/禁用等布尔态 |
+| `slider` | 连续数值,例如透明度、比例、间距强度 |
+| `number` | 精确数值,例如列数、条目数、固定尺寸 |
+| `text` | 需要长期配置的短标题或按钮文案 |
+| `color` | 主题色、强调色、图表主色,优先使用语义色或 token |
+
+最佳实践:
+
+- 多方向单选用 `card`,这是方案对比的默认选择。
+- 布尔状态用 `switch`;简单枚举用 `select`;连续调节用 `slider`。
+- 默认只暴露 3-5 个最影响 UI/UX 的字段,不要把底层 props 全摊开。
+- 文案通常可以直接编辑;只有需要长期配置或和方案切换绑定时才放进 schema。
+- 字段名用用户能理解的语义,不直接暴露第三方库或实现细节。
+
+React 最小形态:
+
+```tsx
+import React from 'react';
+import {
+ createGenieEditorReactTweakStore,
+ useGenieEditorReactTweakStore,
+ useRegisterGenieEditorTweak,
+} from 'axhub-genie-editor-react';
+
+const optionSchema = {
+ title: '多方案探索',
+ fields: [{
+ key: 'variant',
+ label: '方案',
+ type: 'card',
+ options: [
+ { label: '稳健型', description: '继承现有结构,小幅优化体验。', value: 'steady' },
+ { label: '平衡型', description: '调整信息组织和交互路径。', value: 'balanced' },
+ { label: '突破型', description: '探索更有差异的结构和反馈。', value: 'bold' },
+ ],
+ }],
+};
+
+function Example() {
+ const rootRef = React.useRef(null);
+ const store = React.useMemo(
+ () => createGenieEditorReactTweakStore({ variant: 'balanced' }),
+ [],
+ );
+ const values = useGenieEditorReactTweakStore(store);
+
+ useRegisterGenieEditorTweak({
+ elementRef: rootRef,
+ schema: optionSchema,
+ store,
+ onUpdate: async (patch) => store.update(patch),
+ });
+
+ return {renderVariant(values.variant)};
+}
+```
diff --git a/.agents/skills/explore-options/agents/openai.yaml b/.agents/skills/explore-options/agents/openai.yaml
new file mode 100644
index 0000000..38b8f35
--- /dev/null
+++ b/.agents/skills/explore-options/agents/openai.yaml
@@ -0,0 +1,4 @@
+interface:
+ display_name: "多方案探索"
+ short_description: "批注或需求需要多方案、比稿或设计决策时,先比较 2-3 个方向"
+ default_prompt: "使用 $explore-options 对当前批注做多方案探索,形成设计决策后执行最合适的一种。"
diff --git a/.agents/skills/extract-annotation-source/SKILL.md b/.agents/skills/extract-annotation-source/SKILL.md
new file mode 100644
index 0000000..3529823
--- /dev/null
+++ b/.agents/skills/extract-annotation-source/SKILL.md
@@ -0,0 +1,48 @@
+---
+name: extract-annotation-source
+description: Use when an Axhub prototype URL needs its PRD, directory, or annotation context read from window.__AXHUB_ANNOTATION_SOURCE__, especially for prototype-as-PRD review, agent context gathering, or annotation extraction with Playwright, Browser, Chrome, or an equivalent page evaluator.
+---
+
+# Extract Annotation Source
+
+Read Axhub prototype context from the runtime snapshot. Treat the page as read-only.
+
+## Workflow
+
+1. Open the requested prototype URL with Playwright, Browser, Chrome, or an equivalent tool that can evaluate page JavaScript.
+2. Wait until the app renders, then poll briefly for `window.__AXHUB_ANNOTATION_SOURCE__`.
+3. Evaluate and return that value. Do not modify the object or write anything back to `window`.
+4. If the value is missing, report the URL, page title, relevant console errors, and that the annotation runtime snapshot was not published.
+
+Minimal page evaluation:
+
+```js
+await page.waitForFunction(() => window.__AXHUB_ANNOTATION_SOURCE__, { timeout: 10000 });
+const source = await page.evaluate(() => window.__AXHUB_ANNOTATION_SOURCE__);
+```
+
+## What To Report
+
+- Directory / PRD outline from `source.directory`.
+- Markdown or PRD entries from directory nodes with `type: "markdown"`.
+- Annotation count and the important node fields: `id`, `title`, `pageId`, `locator`, `annotationText`, `aiPrompt`, `color`, and `controls`.
+- Source handoff from `source.source` when present. Report `root` and `manifest`; when source is needed, read the manifest and relevant files via `root`.
+- Mention whether images are attached by checking `images.length`; do not download images unless the user asks.
+
+## Data Shape
+
+```ts
+type AnnotationSourceRuntimeSnapshot = {
+ directory: AnnotationDirectory | null;
+ nodes: AnnotationNode[];
+ source?: {
+ root?: string;
+ manifest?: string;
+ };
+};
+```
+
+- `directory.nodes` is the prototype tree. Node types are `folder`, `route`, `link`, and `markdown`.
+- `nodes` is the full annotation list, independent of current page, selected state, and color filter.
+- Each annotation node includes `id`, `index`, optional `title`, optional `pageId`, `locator`, `aiPrompt`, `annotationText`, `hasMarkdown`, `color`, `images`, optional `controls`, `createdAt`, and `updatedAt`.
+- `source` is only a source-code discovery reference. In HTML published with source included, `manifest` points to `source/manifest.json`; resolve its paths relative to `source.root`.
diff --git a/.agents/skills/git-repo-beginner-guide/SKILL.md b/.agents/skills/git-repo-beginner-guide/SKILL.md
new file mode 100644
index 0000000..7238faa
--- /dev/null
+++ b/.agents/skills/git-repo-beginner-guide/SKILL.md
@@ -0,0 +1,162 @@
+---
+name: git-repo-beginner-guide
+description: "面向不熟悉 Git 的产品经理、设计师、运营、文档作者等非技术用户,引导 GitHub/GitLab/Gitee/Bitbucket/自建 Git 平台的仓库初始化、授权、克隆、提交同步、冲突处理、版本恢复和协作交接。"
+---
+
+# Git 仓库新手引导
+
+## Overview
+
+帮助非技术用户安全、低焦虑地使用 Git 仓库协作。默认由 AI 代做可执行操作,并把必要概念翻译成用户能理解的决策点。
+
+## 核心原则
+
+- 先问目标,不先教术语。用户通常要的是“保存一版”“发给团队”“拿到项目”“回到昨天”,不是学习 Git。
+- 先保护现场。任何会丢弃、覆盖、重写历史、删除远程内容的动作,都先解释影响并确认。
+- 默认代办。能由 AI 检查、执行、验证的,不要让用户自己照抄命令;用户只负责授权、选择平台、确认风险。
+- 渐进解释。只有当概念会影响用户决策时才解释,例如远程仓库、提交、同步、冲突。分支、rebase、stash 等先不主动引入。
+- 平台中立。不要默认 GitHub;先识别或询问 GitHub、GitLab、Gitee、Bitbucket、自建 Git、公司内网平台等。
+- 用生活化比喻,但保留必要名词。第一次出现可写“提交 commit:给当前文件拍一张可回退的快照”。
+- 先小步闭环。每次只完成一个可验证状态:已登录、已连接远程、已保存本地版本、已同步到远程、已恢复文件。
+
+## 交互流程
+
+### 1. 先识别用户处境
+
+先用自然语言复述用户目标,然后做最少量澄清。
+
+常见处境:
+
+- “我有一个本地文件夹,想放到代码平台上” → 初始化/连接远程/首次同步
+- “别人给我一个链接,我要拿下来” → 克隆/权限/打开项目
+- “我改完了,想保存并发给别人” → 查看改动/提交/推送
+- “我怕改坏,想留个版本” → 本地提交或备份分支,按复杂度选择
+- “提示要登录/没有权限” → 平台授权引导
+- “提示冲突/推不上去” → 解释有人也改了同一处,先保护本地改动再合并
+- “我要回到之前” → 明确是恢复文件、撤销未提交改动、还是回到某个历史版本
+
+如果上下文已经足够,不要继续追问。直接说“我先帮你检查当前状态”。
+
+### 2. 明确 AI 能帮什么
+
+在开始前用一句话降低用户负担:
+
+> 我可以帮你检查当前文件状态、创建版本记录、连接远程仓库、同步到平台;你只需要在需要网页登录授权时确认账号,或者告诉我目标仓库链接。
+
+避免说“你运行以下命令”。除非当前环境无法代办,才给用户步骤。
+
+### 3. 检查当前状态并翻译结果
+
+检查本地是否是仓库、是否有远程地址、当前有无未保存改动、是否已登录相关平台。回复用户时不要直接堆 `git status` 原文,先给可行动摘要:
+
+- “这个文件夹还不是仓库,我可以把它变成一个可记录版本的文件夹。”
+- “它已经连接到远程平台,地址是 X。”
+- “现在有 6 个文件改动了,还没有保存成版本。”
+- “远程平台需要登录授权,接下来会打开/提供平台自己的登录流程。”
+
+需要展示命令输出时,只摘关键含义。
+
+### 4. 授权引导要平台灵活
+
+先识别远程 URL 或用户指定平台,再选择授权方式:
+
+- GitHub:优先 `gh auth status` / `gh auth login`;必要时解释浏览器 OAuth、SSH、token 的差异。
+- GitLab:优先平台 CLI 或 HTTPS token/SSH;自建 GitLab 注意域名和公司 SSO。
+- Gitee:常见为 HTTPS 凭证、私人令牌或 SSH key;不要套用 GitHub 文案。
+- Bitbucket:注意 workspace、app password、SSH。
+- 自建平台:先问/识别平台地址,按 HTTPS token、SSH key、公司 SSO 三类引导。
+
+授权沟通规范:
+
+- 不要求用户把密码、token、私钥粘贴到聊天里。
+- 需要 token 时,引导用户在平台页面生成,并说明只保存到系统凭据/CLI,不在聊天中泄露。
+- 需要 SSH key 时,先检查是否已有可用 key;没有再创建,并指导用户把公钥加到平台。
+- 遇到 SSO/组织权限时,告诉用户“这是账号权限问题,不是你的文件坏了”。
+
+## 复杂度控制
+
+### 不主动引入分支
+
+如果用户只想把个人文件夹放到远程、保存一版、拉取项目或同步改动,不主动讲分支。
+
+只有在以下情况才引入分支:
+
+- 用户明确要多人协作或不想影响主版本
+- 当前仓库已有分支策略或保护规则
+- 需要在不动主线的情况下尝试修改
+- 提交到主分支被拒绝,需要通过 PR/MR
+
+第一次解释分支时保持短句:
+
+> 分支可以理解成一份独立草稿。你可以在草稿里改,确认没问题后再合到正式版本。
+
+不要一开始讲 rebase、cherry-pick、worktree、detached HEAD。
+
+### 冲突处理面向结果
+
+冲突不是“报错”,而是“同一处内容有两份修改,系统不知道该选哪份”。
+
+处理前先说明:
+
+- 你的本地改动还在
+- 远程也有别人/另一台设备的改动
+- 我会先帮你看冲突位置,再让你决定保留哪部分或合并两边
+
+如果用户不懂代码,优先生成“冲突内容对照摘要”,不要让用户读冲突标记。
+
+### 版本恢复先问恢复粒度
+
+用户说“还原/回退/恢复”时,先判断是哪一种:
+
+- 只撤销还没保存成版本的本地修改
+- 找回某个被删文件
+- 查看之前某个版本
+- 把整个项目回到某个历史版本
+- 远程也要同步回退
+
+远程回退、强推、删除历史前必须明确风险并取得确认。对非技术用户可说:
+
+> 这会改变团队平台上看到的历史版本,可能影响别人。我先给你一个不改远程历史的恢复方案。
+
+## 推荐话术
+
+### 初始化/首次同步
+
+> 我先帮你确认这个文件夹是不是已经有版本记录。如果没有,我会把它变成一个仓库,然后再连接你选择的平台。这个过程不会改变文件内容,只是增加一套版本记录。
+
+### 提交/保存版本
+
+> 现在可以把这次改动保存成一个版本记录。提交信息我会写成人能看懂的一句话,方便以后知道这一版改了什么。
+
+### 推送/同步
+
+> 本地版本已经保存好了。下一步是同步到远程平台,这样团队或另一台电脑才能看到。
+
+### 授权
+
+> 这里需要你用平台账号授权。我不会让你把密码发给我;我们走平台自己的登录流程,完成后我再继续。
+
+### 不确定平台
+
+> 你用的是 GitHub、GitLab、Gitee、Bitbucket,还是公司自己的代码平台?如果你有仓库链接,直接发链接最省事。
+
+## 操作规范
+
+- 执行前先看 `git status`、远程地址和当前分支/默认线索;不要盲目提交或推送。
+- 提交前给用户一个简短变更摘要,确认是否包含敏感文件、临时文件、大文件、设计源文件。
+- 提交信息用用户能理解的语言,但保留常见规范。示例:`docs: update product requirements draft`、`feat: add onboarding flow mockup`。
+- 不把 `.env`、密钥、token、账号导出文件、个人隐私文件提交到仓库;发现时先提醒并处理忽略规则。
+- 不擅自执行 `git reset --hard`、`git clean -fd`、`git push --force`、删除远程分支、覆盖远程历史。
+- 如果工作区已有别人未说明的改动,不要回滚;先说明发现了哪些现有改动,并只处理用户当前目标相关内容。
+- 对新手用户优先给“下一步我来做什么”和“你需要确认什么”,少给完整命令列表。
+
+## 交付要求
+
+完成后用简短摘要告诉用户:
+
+- 当前状态:本地已保存/已连接远程/已同步/仍需授权/遇到冲突
+- 做了什么:初始化、克隆、提交、推送、恢复、创建 issue/PR/MR 等
+- 远程位置:仓库链接或远程地址(如果可用)
+- 用户下一步:打开链接确认、邀请协作者、继续修改、等待权限、选择冲突保留内容
+
+如果没有完成,说明卡在“权限、网络、平台限制、冲突选择、缺少仓库链接”中的哪一类,并给一个最短可继续步骤。
diff --git a/.agents/skills/impeccable/SKILL.md b/.agents/skills/impeccable/SKILL.md
new file mode 100644
index 0000000..ad618f6
--- /dev/null
+++ b/.agents/skills/impeccable/SKILL.md
@@ -0,0 +1,182 @@
+---
+name: impeccable
+description: Use when the user wants to design, redesign, shape, critique, audit, polish, clarify, distill, harden, optimize, adapt, animate, colorize, extract, or otherwise improve a frontend interface. Covers websites, landing pages, dashboards, product UI, app shells, components, forms, settings, onboarding, and empty states. Handles UX review, visual hierarchy, information architecture, cognitive load, accessibility, performance, responsive behavior, theming, anti-patterns, typography, fonts, spacing, layout, alignment, color, motion, micro-interactions, UX copy, error states, edge cases, i18n, and reusable design systems or tokens. Also use for bland designs that need to become bolder or more delightful, loud designs that should become quieter, live browser iteration on UI elements, or ambitious visual effects that should feel technically extraordinary. Not for backend-only or non-UI tasks.
+---
+
+Designs and iterates production-grade frontend interfaces. Real working code, committed design choices, exceptional craft.
+
+## Setup
+
+You MUST do these steps before proceeding:
+
+1. Run `node .agents/skills/impeccable/scripts/context.mjs` once per session. If you've already seen its output in this conversation, do not re-run it. The script either prints the project's PRODUCT.md (and DESIGN.md when present) as a markdown block, or tells you it's missing. Follow whatever it prints. **If it reports `NO_PRODUCT_MD`, stop and follow `reference/init.md` before doing anything else.** If the output ends with an `UPDATE_AVAILABLE` directive, follow it (ask the user once about updating, then continue). It never blocks the current task.
+2. If the user invoked a sub-command (`craft`, `shape`, `audit`, `polish`, ...), you MUST read `reference/.md` next. Non-optional. The reference defines the command's flow; without it you will skip steps the user expects.
+3. Familiarize yourself with any existing design system, conventions, and components in the code. Read at least one project file (CSS / tokens / theme / a representative component or page). **Required even when you've loaded a sub-command reference in step 2.** Don't reinvent the wheel; use what's there when it works, branch out when the UX wins.
+4. Read the matching register reference. **This is non-optional; skipping it produces generic output.** If the project is marketing, a landing page, a campaign, long-form content, or a portfolio (design IS the product), read `reference/brand.md`. If it is app UI, admin, a dashboard, or a tool (design SERVES the product), read `reference/product.md`. Pick by first match: (1) task cue ("landing page" vs "dashboard"); (2) surface in focus (the page, file, or route being worked on); (3) `register` field in PRODUCT.md.
+5. **If the project is brand-new (no existing CSS tokens / theme / committed brand colors found in step 3)**, run `node .agents/skills/impeccable/scripts/palette.mjs` to receive a brand seed color and composition guidance. This is the anchor for your primary brand color. Compose the rest of the palette (bg, surface, ink, accent, muted) around it per the script's instructions. Use OKLCH throughout. **Skip this step only if step 3 found committed brand colors in existing tokens; in that case identity-preservation wins.**
+
+## Design guidance
+
+Produce ready-to-ship, production-grade code, not prototypes or starting points. Take no shortcuts unless the user asks for them (when in doubt, ask). Don't stop until arriving at a complete implementation (beautiful, responsive, fast, precise, bug-free, on brand). You take attention to detail seriously: every page, section or component crafted is battle tested using the tools available to you (browser screenshotting, computer use, etc). GPT is capable of extraordinary work. Don't hold back.
+
+### General rules
+
+#### Color
+
+- **Verify contrast.** Body text must hit ≥4.5:1 against its background; large text (≥18px or bold ≥14px) needs ≥3:1. Placeholder text needs the same 4.5:1, not the muted-gray default. The most common failure: muted gray body text on a tinted near-white. If the contrast is even close, bump the body color toward the ink end of the ramp; light gray "for elegance" is the single biggest reason AI designs feel hard to read.
+- Gray text on a colored background looks washed out. Use a darker shade of the background's own hue, or a transparency of the text color.
+
+#### Typography
+
+- Cap body line length at 65–75ch.
+- Hierarchy through scale + weight contrast (≥1.25 ratio between steps). Avoid flat scales.
+- Cap font-family count at 3 (display + body + optional mono). More than 3 reads as indecision, not richness. One well-tuned family with weight contrast usually beats three competing typefaces.
+- Don't pair fonts that are similar but not identical (two geometric sans-serifs, two humanist sans-serifs). Pair on a contrast axis (serif + sans, geometric + humanist) or use one family in multiple weights.
+- No all-caps body copy. Reserve uppercase for short labels (≤4 words), section eyebrows (used sparingly per the Absolute bans), and badges. Sentences in ALL CAPS are unreadable at body sizes.
+- Hero / display heading ceiling: clamp() max ≤ 6rem (~96px). Above that the page is shouting, not designing.
+- Display heading letter-spacing floor: ≥ -0.04em. Anything tighter and letters touch; cramped, not "designed".
+- Use `text-wrap: balance` on h1–h3 for even line lengths; `text-wrap: pretty` on long prose to reduce orphans.
+
+Two hard typographic ceilings you currently miss:
+- Hero clamp() max ≤ 6rem. 8–11rem (128–176px) reads as comically loud, not bold.
+- Display letter-spacing ≥ -0.04em. Your default of -0.05 to -0.085em on display H1s makes the letters touch and reads as cramped. -0.02 to -0.03em is plenty for tight grotesque display; -0.04em is the floor.
+
+#### Layout
+
+- Vary spacing for rhythm.
+- Cards are the lazy answer. Use them only when they're truly the best affordance. Nested cards are always wrong.
+- Flexbox for 1D, Grid for 2D. Don't default to Grid when `flex-wrap` would be simpler.
+- For responsive grids without breakpoints: `repeat(auto-fit, minmax(280px, 1fr))`.
+- Build a semantic z-index scale (dropdown → sticky → modal-backdrop → modal → toast → tooltip). Never arbitrary values like 999 or 9999.
+
+#### Motion
+- Motion should be intentional, and not be an afterthought. consider it as part of the build.
+- Don't animate CSS layout properties unless truly needed.
+- Ease out with exponential curves (ease-out-quart / quint / expo). No bounce, no elastic.
+- Use libraries for more advanced motion needs (e.g. motion, gsap, anime.js, lenis etc)
+- Reduced motion is not optional. Every animation needs a `@media (prefers-reduced-motion: reduce)` alternative: typically a crossfade or instant transition.
+- Staggering the items within one list is legitimate. The tell is the uniform reflex (one identical entrance applied to every section), not motion itself; each reveal should fit what it reveals. Suppressing the reflex is never a reason to ship a page with no motion at all.
+- Reveal animations must enhance an already-visible default. Don't gate content visibility on a class-triggered transition; transitions pause on hidden tabs and headless renderers, so the reveal never fires and the section ships blank.
+- Premium motion materials are not just transform/opacity. Blur, backdrop-filter, clip-path, mask, and shadow/glow are part of the palette when they materially improve the effect and stay smooth.
+
+#### Interaction
+
+- Dropdowns rendered with `position: absolute` inside an `overflow: hidden` or `overflow: auto` container will be clipped. Use the native `
` open near the top of the document.
+ const idx = content.indexOf(config.insertAfter);
+ if (idx === -1) return content;
+ const after = idx + config.insertAfter.length;
+ // Preserve a single trailing newline if the anchor didn't end with one
+ const prefix = content[after] === '\n' ? content.slice(0, after + 1) : content.slice(0, after) + '\n';
+ return prefix + block + content.slice(prefix.length);
+}
+
+/**
+ * Remove the live script block. Matches either HTML or JSX comment markers
+ * regardless of config (so stale tags from a wrong config can still be cleaned).
+ *
+ * Indent-preserving: captures any whitespace immediately preceding the opener
+ * marker and re-emits it in place of the removed block. `insertTag` inserted
+ * the block *after* the original line's indent and *before* the anchor (e.g.
+ * `
` naturally
+ // belong at the end, and the same literal can appear earlier in code blocks
+ // within rendered documentation pages.
+ if (config.insertBefore) {
+ const idx = content.lastIndexOf(config.insertBefore);
+ if (idx === -1) return content;
+ return content.slice(0, idx) + block + content.slice(idx);
+ }
+ // insertAfter: match the FIRST occurrence — typical anchors like `
` or
+ // `
`), which moved the indent onto the opener line and left the anchor
+ * unindented. Replacing the whole block (plus its trailing newline) with just
+ * the captured indent hands the indent back to the anchor that follows.
+ */
+function removeTag(content, _syntax) {
+ const patterns = [
+ /([ \t]*)[\s\S]*?([ \t]*(?:\n|$)?)/,
+ /([ \t]*)\{\/\*\s*impeccable-live-start\s*\*\/\}[\s\S]*?\{\/\*\s*impeccable-live-end\s*\*\/\}([ \t]*(?:\n|$)?)/,
+ ];
+ for (const pat of patterns) {
+ let changed = false;
+ let next = content;
+ do {
+ content = next;
+ next = content.replace(pat, (_match, leadingIndent, trailing = '') => {
+ if (trailing.includes('\n')) return leadingIndent;
+ return leadingIndent || trailing || '';
+ });
+ if (next !== content) changed = true;
+ } while (next !== content);
+ if (changed) return next;
+ }
+ return content;
+}
+
+// ---------------------------------------------------------------------------
+// Content-Security-Policy meta-tag patcher
+//
+// When the user's HTML carries ``,
+// the cross-origin load of /live.js (and the SSE/POST connection back to
+// localhost:PORT) is blocked unless the CSP explicitly allows that origin.
+//
+// On insert: append `http://localhost:PORT` to `script-src` and `connect-src`,
+// and stash the original `content` value in a `data-impeccable-csp-original`
+// attribute (base64) so revert is exact.
+//
+// On remove: detect the marker attribute, decode it, restore the original
+// content value verbatim, drop the marker.
+//
+// Header-based CSP (Next.js headers, Nuxt routeRules, SvelteKit kit.csp,
+// shared helpers) is NOT patched here — those need framework-specific config
+// edits and are handled via the existing detect-csp.mjs reference output.
+// Only the in-source meta-tag form gets the auto-patch.
+// ---------------------------------------------------------------------------
+
+const CSP_MARKER_ATTR = 'data-impeccable-csp-original';
+
+function findCspMetaTags(content) {
+ const out = [];
+ const tagRe = /]*?)\/?>/gis;
+ let m;
+ while ((m = tagRe.exec(content)) !== null) {
+ const attrs = m[1];
+ if (!/(http-equiv|httpEquiv)\s*=\s*(['"])Content-Security-Policy\2/i.test(attrs)) continue;
+ out.push({ start: m.index, end: m.index + m[0].length, full: m[0], attrs });
+ }
+ return out;
+}
+
+function getAttr(attrs, name) {
+ const re = new RegExp(`\\b${name}\\s*=\\s*(['"])([\\s\\S]*?)\\1`, 'i');
+ const m = attrs.match(re);
+ return m ? { quote: m[1], value: m[2], full: m[0] } : null;
+}
+
+function appendOriginToDirective(csp, directive, origin) {
+ const re = new RegExp(`(^|;)(\\s*)(${directive})\\s+([^;]*)`, 'i');
+ const m = csp.match(re);
+ if (m) {
+ const tokens = m[4].trim().split(/\s+/);
+ if (tokens.includes(origin)) return csp;
+ return csp.replace(re, `${m[1]}${m[2]}${m[3]} ${[...tokens, origin].join(' ')}`);
+ }
+ // Directive missing — add it. Use 'self' + origin so we don't inadvertently
+ // narrow the policy compared to the default-src fallback (most users with
+ // an explicit CSP have 'self' there).
+ return csp.trim().replace(/;?\s*$/, '') + `; ${directive} 'self' ${origin}`;
+}
+
+export function patchCspMeta(content, port) {
+ const tags = findCspMetaTags(content);
+ if (tags.length === 0) return content;
+ const origin = `http://localhost:${port}`;
+
+ // Walk last-to-first so prior splices don't invalidate later indices.
+ let result = content;
+ for (let i = tags.length - 1; i >= 0; i--) {
+ const tag = tags[i];
+ const attrs = tag.attrs;
+ if (getAttr(attrs, CSP_MARKER_ATTR)) continue; // already patched
+ const contentAttr = getAttr(attrs, 'content');
+ if (!contentAttr) continue;
+
+ const original = contentAttr.value;
+ let patched = original;
+ patched = appendOriginToDirective(patched, 'script-src', origin);
+ patched = appendOriginToDirective(patched, 'connect-src', origin);
+ // The shader overlay during 'generating' creates a screenshot via
+ // URL.createObjectURL, producing a `blob:` URL — img-src 'self' rejects
+ // those. Add `blob:` so the overlay doesn't throw a CSP violation.
+ patched = appendOriginToDirective(patched, 'img-src', 'blob:');
+ if (patched === original) continue;
+
+ const newContentAttr = `content=${contentAttr.quote}${patched}${contentAttr.quote}`;
+ const marker = `${CSP_MARKER_ATTR}="${Buffer.from(original, 'utf-8').toString('base64')}"`;
+ // The tagRe captures any whitespace between the last attribute and the
+ // closing `/>` as part of `attrs`. Naively appending ` ${marker}` after
+ // a replace would land it BEFORE that trailing space, leaving a double
+ // space inside attrs and clobbering the space before `/>`. Split off
+ // the trailing whitespace, splice the marker into the attribute body,
+ // and re-append the original trailing whitespace so a self-closing
+ // `` round-trips byte-for-byte.
+ const trailingWs = (attrs.match(/[ \t]*$/) || [''])[0];
+ const attrsBody = attrs.slice(0, attrs.length - trailingWs.length);
+ const newAttrs = attrsBody.replace(contentAttr.full, newContentAttr) + ' ' + marker + trailingWs;
+ const newTag = tag.full.replace(attrs, newAttrs);
+
+ result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+ }
+ return result;
+}
+
+export function revertCspMeta(content) {
+ const tags = findCspMetaTags(content);
+ if (tags.length === 0) return content;
+
+ let result = content;
+ for (let i = tags.length - 1; i >= 0; i--) {
+ const tag = tags[i];
+ const origAttr = getAttr(tag.attrs, CSP_MARKER_ATTR);
+ if (!origAttr) continue;
+ const contentAttr = getAttr(tag.attrs, 'content');
+ if (!contentAttr) continue;
+
+ let originalValue;
+ try { originalValue = Buffer.from(origAttr.value, 'base64').toString('utf-8'); }
+ catch { continue; }
+
+ const newContentAttr = `content=${contentAttr.quote}${originalValue}${contentAttr.quote}`;
+ let newAttrs = tag.attrs.replace(contentAttr.full, newContentAttr);
+ // Drop the marker attribute and any single space immediately preceding it.
+ newAttrs = newAttrs.replace(new RegExp(`\\s*${origAttr.full}`), '');
+ const newTag = tag.full.replace(tag.attrs, newAttrs);
+
+ result = result.slice(0, tag.start) + newTag + result.slice(tag.end);
+ }
+ return result;
+}
+
+// ---------------------------------------------------------------------------
+// Auto-execute
+// ---------------------------------------------------------------------------
+
+const _running = process.argv[1];
+if (_running?.endsWith('live-inject.mjs') || _running?.endsWith('live-inject.mjs/')) {
+ injectCli();
+}
+
+export { insertTag, removeTag, validateConfig, buildTagBlock };
+// patchCspMeta + revertCspMeta are exported above where they're defined.
diff --git a/.claude/skills/impeccable/scripts/live-insert-ui.mjs b/.claude/skills/impeccable/scripts/live-insert-ui.mjs
new file mode 100644
index 0000000..ae54f6f
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/live-insert-ui.mjs
@@ -0,0 +1,458 @@
+/**
+ * Pure helpers for live-mode insert UI (browser + tests).
+ * Kept separate from live-browser.js so insert logic is unit-testable.
+ */
+
+export const PLACEHOLDER_DEFAULT_HEIGHT = 80;
+export const PLACEHOLDER_MIN_HEIGHT = 48;
+export const PLACEHOLDER_MIN_WIDTH = 120;
+
+/** @typedef {'before' | 'after'} InsertPosition */
+/** @typedef {'row' | 'column'} InsertAxis */
+
+/**
+ * Infer sibling flow axis from a container's computed layout styles.
+ * @param {{ display?: string, flexDirection?: string, gridTemplateColumns?: string, gridAutoFlow?: string }} style
+ * @returns {InsertAxis}
+ */
+export function detectInsertAxisFromStyle(style) {
+ const display = style?.display || 'block';
+ if (display.includes('flex')) {
+ const dir = style.flexDirection || 'row';
+ return dir.startsWith('row') ? 'row' : 'column';
+ }
+ if (display === 'grid' || display === 'inline-grid') {
+ const flow = style.gridAutoFlow || 'row';
+ if (flow.includes('column')) return 'column';
+ const cols = (style.gridTemplateColumns || '').trim();
+ if (cols && cols !== 'none') {
+ const colCount = cols.split(/\s+/).filter(Boolean).length;
+ if (colCount > 1) return 'row';
+ }
+ return 'row';
+ }
+ return 'column';
+}
+
+/**
+ * Pick insertion side from pointer position against an anchor element box.
+ * @param {number} clientX
+ * @param {number} clientY
+ * @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect
+ * @param {InsertAxis} [axis]
+ * @returns {InsertPosition}
+ */
+export function computeInsertPosition(clientX, clientY, rect, axis = 'column') {
+ if (!rect) return 'after';
+ if (axis === 'row') {
+ if (!Number.isFinite(rect.left) || !Number.isFinite(rect.width) || rect.width <= 0) return 'after';
+ const mid = rect.left + rect.width / 2;
+ return clientX < mid ? 'before' : 'after';
+ }
+ if (!Number.isFinite(rect.top) || !Number.isFinite(rect.height) || rect.height <= 0) return 'after';
+ const mid = rect.top + rect.height / 2;
+ return clientY < mid ? 'before' : 'after';
+}
+
+/**
+ * Whether Create is allowed for an insert session.
+ * Requires a non-empty prompt OR at least one annotation.
+ */
+export function canCreateInsert({ prompt, comments, strokes }) {
+ const hasPrompt = typeof prompt === 'string' && prompt.trim().length > 0;
+ const hasComments = Array.isArray(comments) && comments.length > 0;
+ const hasStrokes = Array.isArray(strokes) && strokes.some(
+ (s) => Array.isArray(s?.points) && s.points.length >= 2,
+ );
+ return hasPrompt || hasComments || hasStrokes;
+}
+
+/** Tooltip/title when Create is disabled. */
+export function insertCreateDisabledReason({ prompt, comments, strokes }) {
+ if (canCreateInsert({ prompt, comments, strokes })) return null;
+ return 'Add a prompt or annotate the placeholder to create';
+}
+
+/**
+ * Fixed-position insert line coordinates (viewport px).
+ * @param {{ top: number, left: number, width: number, height: number, bottom?: number, right?: number }} rect
+ * @param {InsertPosition} position
+ * @param {InsertAxis} [axis]
+ */
+export function insertLineCoords(rect, position, axis = 'column') {
+ if (axis === 'row') {
+ const right = rect.right ?? rect.left + rect.width;
+ const x = position === 'before' ? rect.left - 2 : right + 2;
+ return { axis: 'row', top: rect.top, left: x, width: 0, height: rect.height };
+ }
+ const bottom = rect.bottom ?? rect.top + rect.height;
+ const y = position === 'before' ? rect.top - 2 : bottom + 2;
+ return { axis: 'column', top: y, left: rect.left, width: rect.width, height: 0 };
+}
+
+/** Cursor while hovering an insert boundary. */
+export function cursorForInsertAxis(axis) {
+ return axis === 'row' ? 'ew-resize' : 'ns-resize';
+}
+
+function groupSiblingRows(siblings, rowThreshold = 8) {
+ const sorted = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);
+ const rows = [];
+ for (const entry of sorted) {
+ let placed = false;
+ for (const row of rows) {
+ if (Math.abs(entry.rect.top - row[0].rect.top) <= rowThreshold) {
+ row.push(entry);
+ placed = true;
+ break;
+ }
+ }
+ if (!placed) rows.push([entry]);
+ }
+ return rows;
+}
+
+function horizontalOverlap(a, b) {
+ const left = Math.max(a.left, b.left);
+ const right = Math.min(a.right ?? a.left + a.width, b.right ?? b.left + b.width);
+ return Math.max(0, right - left);
+}
+
+/**
+ * Hit-test the gap between adjacent siblings (flex rows, grid columns, stacked blocks).
+ * @param {number} clientX
+ * @param {number} clientY
+ * @param {Array<{ el: unknown, rect: { top: number, left: number, width: number, height: number, bottom?: number, right?: number } }>} siblings
+ * @param {{ slop?: number, minOverlap?: number }} [opts]
+ */
+export function hitSiblingInsertGap(clientX, clientY, siblings, opts = {}) {
+ if (!Array.isArray(siblings) || siblings.length < 2) return null;
+ const slop = opts.slop ?? 12;
+ const minOverlap = opts.minOverlap ?? 0.25;
+
+ for (const row of groupSiblingRows(siblings)) {
+ if (row.length < 2) continue;
+ const sorted = [...row].sort((a, b) => a.rect.left - b.rect.left);
+ for (let i = 0; i < sorted.length - 1; i++) {
+ const a = sorted[i];
+ const b = sorted[i + 1];
+ const aRight = a.rect.right ?? a.rect.left + a.rect.width;
+ const bLeft = b.rect.left;
+ if (bLeft <= aRight) continue;
+ const top = Math.max(a.rect.top, b.rect.top);
+ const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height;
+ const bBottom = b.rect.bottom ?? b.rect.top + b.rect.height;
+ const bottom = Math.min(aBottom, bBottom);
+ const span = bottom - top;
+ const minH = Math.min(a.rect.height, b.rect.height);
+ if (span < minH * minOverlap) continue;
+
+ const inX = clientX >= aRight - slop && clientX <= bLeft + slop;
+ const inY = clientY >= top - slop && clientY <= bottom + slop;
+ if (!inX || !inY) continue;
+
+ const midX = (aRight + bLeft) / 2;
+ return {
+ anchor: b.el,
+ position: 'before',
+ axis: 'row',
+ line: { axis: 'row', left: midX, top, width: 0, height: span },
+ };
+ }
+ }
+
+ const sortedCol = [...siblings].sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left);
+ for (let i = 0; i < sortedCol.length - 1; i++) {
+ const a = sortedCol[i];
+ const b = sortedCol[i + 1];
+ const overlap = horizontalOverlap(a.rect, b.rect);
+ const minW = Math.min(a.rect.width, b.rect.width);
+ if (overlap < minW * minOverlap) continue;
+
+ const aBottom = a.rect.bottom ?? a.rect.top + a.rect.height;
+ const gapTop = aBottom;
+ const gapBottom = b.rect.top;
+ if (gapBottom <= gapTop) continue;
+
+ const overlapLeft = Math.max(a.rect.left, b.rect.left);
+ const overlapRight = Math.min(
+ a.rect.right ?? a.rect.left + a.rect.width,
+ b.rect.right ?? b.rect.left + b.rect.width,
+ );
+ const inY = clientY >= gapTop - slop && clientY <= gapBottom + slop;
+ const inX = clientX >= overlapLeft - slop && clientX <= overlapRight + slop;
+ if (!inY || !inX) continue;
+
+ const midY = (gapTop + gapBottom) / 2;
+ return {
+ anchor: b.el,
+ position: 'before',
+ axis: 'column',
+ line: { axis: 'column', top: midY, left: overlapLeft, width: overlap, height: 0 },
+ };
+ }
+
+ return null;
+}
+
+/**
+ * Resolve insert hover target, side, axis, and indicator line for the pointer.
+ */
+export function resolveInsertHover({ clientX, clientY, target, rect, axis, siblings }) {
+ const gap = hitSiblingInsertGap(clientX, clientY, siblings);
+ if (gap) return gap;
+
+ const position = computeInsertPosition(clientX, clientY, rect, axis);
+ const line = insertLineCoords(rect, position, axis);
+ return { anchor: target, position, axis, line };
+}
+
+/**
+ * How the in-flow placeholder should participate in layout.
+ * Prefer implicit sizing (flex / %) so row inserts don't inherit the full parent width in px.
+ * @returns {{ kind: 'flex', flex: string, minWidth: number } | { kind: 'percent' } | { kind: 'auto' } | { kind: 'explicit', width: number }}
+ */
+export function placeholderSizing({ axis, parentDisplay, parentWidth, anchorFlex }) {
+ const display = parentDisplay || 'block';
+ const w = Number.isFinite(parentWidth) ? parentWidth : 0;
+
+ if (axis === 'row') {
+ if (display.includes('flex')) {
+ const flex = anchorFlex && anchorFlex !== 'none' && anchorFlex !== '0 1 auto'
+ ? anchorFlex
+ : '1 1 0';
+ return { kind: 'flex', flex, minWidth: 0 };
+ }
+ if (display === 'grid' || display === 'inline-grid') {
+ return { kind: 'auto' };
+ }
+ }
+
+ if (w >= PLACEHOLDER_MIN_WIDTH) {
+ return { kind: 'percent' };
+ }
+
+ return {
+ kind: 'explicit',
+ width: Math.max(PLACEHOLDER_MIN_WIDTH, w || PLACEHOLDER_MIN_WIDTH),
+ };
+}
+
+/** Width kinds that need materializing to px before edge-resize. */
+export function placeholderWidthIsImplicit(kind) {
+ return kind === 'flex' || kind === 'percent' || kind === 'auto';
+}
+
+/**
+ * Clamp user-resized placeholder dimensions.
+ */
+export function clampPlaceholderSize(width, height, parentWidth, opts = {}) {
+ const minW = opts.minWidth ?? PLACEHOLDER_MIN_WIDTH;
+ const minH = opts.minHeight ?? PLACEHOLDER_MIN_HEIGHT;
+ const maxW = opts.maxWidth ?? Math.max(minW, parentWidth || minW);
+ return {
+ width: Math.min(maxW, Math.max(minW, Math.round(width))),
+ height: Math.max(minH, Math.round(height)),
+ };
+}
+
+/** CSS cursor for a placeholder edge resize handle. */
+export function cursorForPlaceholderEdge(edge) {
+ if (edge === 'n' || edge === 's') return 'ns-resize';
+ if (edge === 'e' || edge === 'w') return 'ew-resize';
+ return 'default';
+}
+
+/**
+ * Compute placeholder box after dragging one edge (in-flow margins shift for n/w).
+ * @param {{ width: number, height: number, marginLeft?: number, marginTop?: number }} start
+ * @param {'n'|'e'|'s'|'w'} edge
+ * @param {number} dx pointer delta X since drag start
+ * @param {number} dy pointer delta Y since drag start
+ * @param {number} parentWidth
+ */
+export function resizePlaceholderFromEdge(start, edge, dx, dy, parentWidth, opts = {}) {
+ const base = {
+ width: start.width,
+ height: start.height,
+ marginLeft: start.marginLeft ?? 0,
+ marginTop: start.marginTop ?? 0,
+ };
+ if (edge === 'e') base.width = start.width + dx;
+ else if (edge === 'w') {
+ base.width = start.width - dx;
+ base.marginLeft = start.marginLeft + dx;
+ } else if (edge === 's') base.height = start.height + dy;
+ else if (edge === 'n') {
+ base.height = start.height - dy;
+ base.marginTop = start.marginTop + dy;
+ }
+
+ const clamped = clampPlaceholderSize(base.width, base.height, parentWidth, opts);
+ if (edge === 'w') {
+ base.marginLeft = start.marginLeft + start.width - clamped.width;
+ } else if (edge === 'n') {
+ base.marginTop = start.marginTop + start.height - clamped.height;
+ }
+
+ return {
+ width: clamped.width,
+ height: clamped.height,
+ marginLeft: Math.round(base.marginLeft),
+ marginTop: Math.round(base.marginTop),
+ };
+}
+
+/** Pick and insert toggles are independent but turning one ON turns the other OFF. */
+export function applyPickToggle(pickActive, insertActive) {
+ const nextPick = !pickActive;
+ return {
+ pickActive: nextPick,
+ insertActive: nextPick ? false : insertActive,
+ };
+}
+
+export function applyInsertToggle(pickActive, insertActive) {
+ const nextInsert = !insertActive;
+ return {
+ pickActive: nextInsert ? false : pickActive,
+ insertActive: nextInsert,
+ };
+}
+
+/**
+ * Build the browser generate payload for insert mode.
+ */
+export function buildInsertGeneratePayload({
+ id,
+ count,
+ pageUrl,
+ anchorContext,
+ position,
+ placeholder,
+ freeformPrompt,
+ comments,
+ strokes,
+ screenshotPath,
+}) {
+ const payload = {
+ type: 'generate',
+ mode: 'insert',
+ id,
+ count,
+ pageUrl,
+ insert: {
+ position,
+ anchor: anchorContext,
+ },
+ placeholder,
+ freeformPrompt: freeformPrompt?.trim() || undefined,
+ };
+ if (comments?.length) payload.comments = comments;
+ if (strokes?.length) payload.strokes = strokes;
+ if (screenshotPath) payload.screenshotPath = screenshotPath;
+ return payload;
+}
+
+/**
+ * Whether a variant wrapper is currently shown (handles `hidden` and display:none).
+ * @param {{ hidden?: boolean, style?: { display?: string } } | null | undefined} el
+ */
+export function isVariantShown(el) {
+ if (!el) return false;
+ if (el.hidden) return false;
+ if (el.style?.display === 'none') return false;
+ return true;
+}
+
+/**
+ * Show or hide a variant wrapper for cycling.
+ * @param {{ hidden?: boolean, style?: { display?: string }, removeAttribute?: (name: string) => void, setAttribute?: (name: string, value?: string) => void } | null | undefined} el
+ * @param {boolean} shown
+ */
+export function setVariantShown(el, shown) {
+ if (!el) return;
+ if (shown) {
+ el.removeAttribute?.('hidden');
+ if (el.style) el.style.display = '';
+ } else {
+ el.setAttribute?.('hidden', '');
+ if (el.style) el.style.display = 'none';
+ }
+}
+
+/**
+ * Pick the best live anchor during an insert session (placeholder until variants land).
+ * @param {{
+ * wrapper?: unknown,
+ * variantCount?: number,
+ * visibleVariant?: number,
+ * placeholder?: unknown,
+ * insertAnchor?: unknown,
+ * pickVariantContent?: (wrapper: unknown, index: number) => unknown,
+ * }} opts
+ */
+export function resolveInsertSessionAnchor(opts) {
+ const {
+ wrapper,
+ variantCount = 0,
+ visibleVariant = 0,
+ placeholder,
+ insertAnchor,
+ pickVariantContent,
+ } = opts || {};
+ if (wrapper && variantCount > 0 && visibleVariant > 0 && pickVariantContent) {
+ const vis = pickVariantContent(wrapper, visibleVariant);
+ if (vis) return vis;
+ }
+ return placeholder || insertAnchor || null;
+}
+
+/**
+ * Snapshot placeholder geometry + anchor fingerprint so HMR can recreate the box.
+ * @param {{
+ * tagName?: string,
+ * className?: string,
+ * textContent?: string,
+ * }} anchor
+ * @param {{
+ * offsetWidth?: number,
+ * offsetHeight?: number,
+ * style?: { marginLeft?: string, marginTop?: string },
+ * }} placeholder
+ * @param {{ position: 'before' | 'after', layoutAxis?: 'row' | 'column' }} meta
+ */
+export function buildInsertPlaceholderSnapshot(anchor, placeholder, { position, layoutAxis }) {
+ return {
+ width: Math.round(placeholder.offsetWidth || 0),
+ height: Math.round(placeholder.offsetHeight || PLACEHOLDER_DEFAULT_HEIGHT),
+ marginLeft: parseFloat(placeholder.style?.marginLeft || '') || 0,
+ marginTop: parseFloat(placeholder.style?.marginTop || '') || 0,
+ position,
+ layoutAxis: layoutAxis || 'column',
+ anchorTag: anchor.tagName || 'DIV',
+ anchorClasses: anchor.className || '',
+ anchorText: (anchor.textContent || '').trim().slice(0, 120),
+ };
+}
+
+/**
+ * Re-find an insert anchor after framework HMR replaced the live DOM node.
+ * @param {Pick} doc
+ * @param {ReturnType | null | undefined} snapshot
+ * @param {Element | null | undefined} liveAnchor
+ */
+export function findInsertAnchorInDom(doc, snapshot, liveAnchor = null) {
+ if (liveAnchor && doc.body.contains(liveAnchor)) return liveAnchor;
+ if (!snapshot) return null;
+ const tag = (snapshot.anchorTag || 'div').toLowerCase();
+ const cls = (snapshot.anchorClasses || '').split(/\s+/).filter(Boolean)[0];
+ const needle = snapshot.anchorText || '';
+ const sel = cls ? `${tag}.${cls}` : tag;
+ const candidates = doc.querySelectorAll(sel);
+ for (const candidate of candidates) {
+ if (needle && !(candidate.textContent || '').includes(needle.slice(0, 40))) continue;
+ return candidate;
+ }
+ return null;
+}
diff --git a/.claude/skills/impeccable/scripts/live-insert.mjs b/.claude/skills/impeccable/scripts/live-insert.mjs
new file mode 100644
index 0000000..09d4d55
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/live-insert.mjs
@@ -0,0 +1,232 @@
+/**
+ * CLI helper: find an anchor element in source and splice an insert-variant
+ * wrapper before or after it (no original variant — net-new content).
+ *
+ * Usage:
+ * node live-insert.mjs --id SESSION_ID --count N --position after \
+ * --classes "hero" --tag section [--file path]
+ */
+
+import fs from 'node:fs';
+import path from 'node:path';
+import { isGeneratedFile } from './is-generated.mjs';
+import {
+ buildSearchQueries,
+ findElement,
+ findAllElements,
+ filterByText,
+ findFileWithQuery,
+ detectCommentSyntax,
+ detectStyleMode,
+ buildCssAuthoring,
+ buildCssSelectorPrefixExamples,
+} from './live-wrap.mjs';
+
+const INSERT_POSITIONS = new Set(['before', 'after']);
+
+export function isInsertPosition(value) {
+ return INSERT_POSITIONS.has(value);
+}
+
+export function computeInsertLine(startLine, endLine, position) {
+ return position === 'before' ? startLine : endLine + 1;
+}
+
+export function buildInsertWrapperLines({ id, count, indent, commentSyntax, isJsx }) {
+ const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
+ const attrs =
+ 'data-impeccable-variants="' + id + '" ' +
+ 'data-impeccable-mode="insert" ' +
+ 'data-impeccable-variant-count="' + count + '" ' +
+ styleContents;
+
+ if (isJsx) {
+ return [
+ indent + '
',
+ indent + commentSyntax.open + ' impeccable-variants-end ' + id + ' ' + commentSyntax.close,
+ ];
+}
+
+function argVal(args, flag) {
+ const idx = args.indexOf(flag);
+ return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
+}
+
+function resolveElementMatch({ lines, queries, tag, text }) {
+ if (text) {
+ const candidates = [];
+ for (const q of queries) {
+ const all = findAllElements(lines, q, tag);
+ for (const c of all) {
+ if (!candidates.some((x) => x.startLine === c.startLine)) candidates.push(c);
+ }
+ if (candidates.length === 1) break;
+ }
+ if (candidates.length === 0) return { error: 'element_not_found' };
+ if (candidates.length === 1) return { match: candidates[0] };
+ const filtered = filterByText(candidates, lines, text);
+ if (filtered.length === 1) return { match: filtered[0] };
+ if (filtered.length === 0) return { match: candidates[0] };
+ return { error: 'element_ambiguous', candidates: filtered };
+ }
+
+ for (const q of queries) {
+ const match = findElement(lines, q, tag);
+ if (match) return { match };
+ }
+ return { error: 'element_not_found' };
+}
+
+export async function insertCli() {
+ const args = process.argv.slice(2);
+
+ if (args.includes('--help') || args.includes('-h')) {
+ console.log(`Usage: node live-insert.mjs [options]
+
+Find an anchor element in source and splice an insert-variant wrapper.
+
+Required:
+ --id ID Session ID for the variant wrapper
+ --count N Number of expected variants (1-8)
+ --position POS before | after (relative to the anchor element)
+
+Element identification (at least one required):
+ --element-id ID HTML id attribute of the anchor element
+ --classes A,B,C Comma-separated CSS class names
+ --tag TAG Tag name (div, section, etc.)
+ --query TEXT Fallback: raw text to search for
+
+Optional:
+ --file PATH Source file to search in (skips auto-detection)
+ --text TEXT Anchor textContent for disambiguation (~80 chars)
+
+Output (JSON):
+ { mode: "insert", file, position, insertLine, commentSyntax, styleMode, styleTag, cssAuthoring }`);
+ process.exit(0);
+ }
+
+ const id = argVal(args, '--id');
+ const count = parseInt(argVal(args, '--count') || '3', 10);
+ const position = argVal(args, '--position');
+ const elementId = argVal(args, '--element-id');
+ const classes = argVal(args, '--classes');
+ const tag = argVal(args, '--tag');
+ const query = argVal(args, '--query');
+ const filePath = argVal(args, '--file');
+ const text = argVal(args, '--text');
+
+ if (!id) { console.error('Missing --id'); process.exit(1); }
+ if (!position) { console.error('Missing --position (before | after)'); process.exit(1); }
+ if (!isInsertPosition(position)) { console.error('Invalid --position: ' + position); process.exit(1); }
+ if (!elementId && !classes && !query) {
+ console.error('Need at least one of: --element-id, --classes, --query');
+ process.exit(1);
+ }
+
+ const queries = buildSearchQueries(elementId, classes, tag, query);
+ const genOpts = { cwd: process.cwd() };
+
+ let targetFile = filePath;
+ if (!targetFile) {
+ for (const q of queries) {
+ targetFile = findFileWithQuery(q, process.cwd(), genOpts);
+ if (targetFile) break;
+ }
+ if (!targetFile) {
+ let generatedHit = null;
+ for (const q of queries) {
+ generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
+ if (generatedHit) break;
+ }
+ console.error(JSON.stringify({
+ error: generatedHit ? 'element_not_in_source' : 'element_not_found',
+ fallback: 'agent-driven',
+ hint: 'See "Handle fallback" in live.md.',
+ }));
+ process.exit(1);
+ }
+ } else if (isGeneratedFile(targetFile, genOpts)) {
+ console.error(JSON.stringify({
+ error: 'file_is_generated',
+ fallback: 'agent-driven',
+ file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
+ }));
+ process.exit(1);
+ }
+
+ const content = fs.readFileSync(targetFile, 'utf-8');
+ const lines = content.split('\n');
+ const resolved = resolveElementMatch({ lines, queries, tag, text });
+
+ if (resolved.error === 'element_ambiguous') {
+ console.error(JSON.stringify({
+ error: 'element_ambiguous',
+ fallback: 'agent-driven',
+ file: path.relative(process.cwd(), targetFile),
+ candidates: resolved.candidates.map((c) => ({
+ startLine: c.startLine + 1,
+ endLine: c.endLine + 1,
+ })),
+ }));
+ process.exit(1);
+ }
+ if (!resolved.match) {
+ console.error(JSON.stringify({ error: 'element_not_found', fallback: 'agent-driven' }));
+ process.exit(1);
+ }
+
+ const { startLine, endLine } = resolved.match;
+ const commentSyntax = detectCommentSyntax(targetFile);
+ const styleMode = detectStyleMode(targetFile);
+ const isJsx = commentSyntax.open === '{/*';
+ const spliceIndex = computeInsertLine(startLine, endLine, position);
+ const indent = lines[spliceIndex]?.match(/^(\s*)/)?.[1]
+ ?? lines[startLine]?.match(/^(\s*)/)?.[1]
+ ?? '';
+
+ const wrapperLines = buildInsertWrapperLines({
+ id,
+ count,
+ indent,
+ commentSyntax,
+ isJsx,
+ });
+
+ const newLines = [
+ ...lines.slice(0, spliceIndex),
+ ...wrapperLines,
+ ...lines.slice(spliceIndex),
+ ];
+ fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
+
+ const insertLine = spliceIndex + 3;
+
+ console.log(JSON.stringify({
+ mode: 'insert',
+ position,
+ file: path.relative(process.cwd(), targetFile),
+ insertLine: insertLine + 1,
+ commentSyntax,
+ styleMode: styleMode.mode,
+ styleTag: styleMode.styleTag,
+ cssSelectorPrefixExamples: buildCssSelectorPrefixExamples(styleMode.mode, count),
+ cssAuthoring: buildCssAuthoring(styleMode, count),
+ }));
+}
+
+const _running = process.argv[1];
+if (_running?.endsWith('live-insert.mjs') || _running?.endsWith('live-insert.mjs/')) {
+ insertCli();
+}
diff --git a/.claude/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.claude/skills/impeccable/scripts/live-manual-edit-evidence.mjs
new file mode 100644
index 0000000..860278b
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/live-manual-edit-evidence.mjs
@@ -0,0 +1,363 @@
+#!/usr/bin/env node
+/**
+ * Collect evidence for pending live copy edits.
+ *
+ * This module intentionally does not edit source files and does not choose a
+ * winner. It gathers staged browser edits, rendered context, framework source
+ * hints, and likely source candidates so the AI copy-edit batch runner can make
+ * source changes with full repo context.
+ */
+
+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';
+
+const EVIDENCE_VERSION = 1;
+const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']);
+const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data'];
+const STRONG_LITERAL_MATCH_LIMIT = 8;
+const WEAK_LITERAL_MATCH_LIMIT = 4;
+const OBJECT_KEY_MATCH_LIMIT = 8;
+const LOCATOR_MATCH_LIMIT = 4;
+const CONTEXT_MATCH_LIMIT = 8;
+const CONTEXT_MATCH_PER_HINT = 2;
+const SKIP_DIRS = new Set([
+ 'node_modules',
+ '.git',
+ '.impeccable',
+ '.astro',
+ '.next',
+ '.nuxt',
+ '.svelte-kit',
+ 'dist',
+ 'build',
+ 'out',
+ 'coverage',
+]);
+
+export function buildManualEditEvidence({ cwd = process.cwd(), pageUrl = null } = {}) {
+ const buffer = readBuffer(cwd);
+ const entries = pageUrl
+ ? buffer.entries.filter((entry) => entry.pageUrl === pageUrl)
+ : buffer.entries;
+ const opCount = countOps(entries);
+
+ if (opCount === 0) {
+ return {
+ pageUrl,
+ count: 0,
+ entries: [],
+ ops: [],
+ candidates: [],
+ };
+ }
+
+ const searchFiles = collectSearchFiles(cwd);
+ const ops = flattenOps(entries);
+ const candidates = ops.map((op) => buildCandidatesForOp(op, cwd, searchFiles));
+ return {
+ version: EVIDENCE_VERSION,
+ pageUrl: pageUrl || null,
+ count: opCount,
+ entries,
+ ops,
+ context: {
+ cwd,
+ bufferPath: path.relative(cwd, getBufferPath(cwd)),
+ totalEntries: entries.length,
+ totalOps: opCount,
+ },
+ candidates,
+ };
+}
+
+function countOps(entries) {
+ let count = 0;
+ for (const entry of entries) count += Array.isArray(entry.ops) ? entry.ops.length : 0;
+ return count;
+}
+
+function flattenOps(entries) {
+ const out = [];
+ for (const entry of entries) {
+ const contextHintsByRef = buildContextHintsByRef(entry);
+ for (const op of entry.ops || []) {
+ out.push({
+ entryId: entry.id,
+ pageUrl: entry.pageUrl,
+ ref: op.ref,
+ contextRef: op.contextRef || null,
+ tag: op.tag,
+ elementId: op.elementId || null,
+ classes: Array.isArray(op.classes) ? op.classes : [],
+ originalText: op.originalText,
+ newText: op.newText,
+ deleted: op.deleted === true,
+ sourceHint: op.sourceHint || null,
+ leaf: op.leaf || null,
+ nearbyEditableTexts: Array.isArray(op.nearbyEditableTexts) ? op.nearbyEditableTexts : [],
+ container: op.container || null,
+ contextHints: contextHintsByRef.get(op.ref) || [],
+ });
+ }
+ }
+ return out;
+}
+
+function buildContextHintsByRef(entry) {
+ const map = new Map();
+ for (const op of entry.ops || []) {
+ const hints = new Set();
+ const add = (value) => {
+ const text = normalizeText(decodeBasicHtml(String(value || '')));
+ if (text.length < 3 || text.length > 160) return;
+ if (text === normalizeText(op.originalText) || text === normalizeText(op.newText)) return;
+ hints.add(text);
+ };
+
+ for (const item of op.nearbyEditableTexts || []) {
+ add(typeof item === 'string' ? item : item?.text);
+ }
+ const outer = typeof entry.element?.outerHTML === 'string' ? entry.element.outerHTML : '';
+ for (const match of outer.matchAll(/data-impeccable-original-text="([^"]*)"/g)) add(match[1]);
+ if (typeof entry.element?.textContent === 'string') {
+ for (const chunk of entry.element.textContent.split(/\s{2,}|\n|\t/)) add(chunk);
+ }
+ map.set(op.ref, [...hints].slice(0, 16));
+ }
+ return map;
+}
+
+function buildCandidatesForOp(op, cwd, searchFiles) {
+ const originalText = String(op.originalText || '');
+ const contextNeedles = op.contextHints || [];
+ return {
+ entryId: op.entryId,
+ ref: op.ref,
+ originalText,
+ sourceHint: analyzeSourceHint(op, cwd),
+ textMatches: originalText ? findLiteralMatches(searchFiles, originalText, { max: literalMatchLimit(originalText) }) : [],
+ objectKeyMatches: originalText ? findObjectKeyMatches(searchFiles, originalText, { max: OBJECT_KEY_MATCH_LIMIT }) : [],
+ locatorMatches: findLocatorMatches(searchFiles, op, { max: LOCATOR_MATCH_LIMIT }),
+ contextTextMatches: findContextMatches(searchFiles, contextNeedles, { maxPerHint: CONTEXT_MATCH_PER_HINT, max: CONTEXT_MATCH_LIMIT }),
+ };
+}
+
+function literalMatchLimit(text) {
+ return isWeakSourceNeedle(text) ? WEAK_LITERAL_MATCH_LIMIT : STRONG_LITERAL_MATCH_LIMIT;
+}
+
+function isWeakSourceNeedle(text) {
+ const normalized = normalizeText(text);
+ return normalized.length < 4 || /^[\d.,+\-%\s]+$/.test(normalized);
+}
+
+function analyzeSourceHint(op, cwd) {
+ const hint = normalizeSourceHint(op.sourceHint);
+ if (!hint.file) return null;
+ const file = path.resolve(cwd, hint.file);
+ const relativeFile = path.relative(cwd, file);
+ if (!isPathInsideOrEqual(cwd, file)) {
+ return { ...hint, status: 'outside_cwd', relativeFile: hint.file };
+ }
+ if (!fs.existsSync(file)) {
+ return { ...hint, status: 'file_missing', relativeFile };
+ }
+ if (isGeneratedFile(file, { cwd })) {
+ return { ...hint, status: 'generated', relativeFile };
+ }
+
+ const content = fs.readFileSync(file, 'utf-8');
+ const lines = content.split('\n');
+ const line = hint.line || 1;
+ const start = Math.max(0, line - 4);
+ const end = Math.min(lines.length, line + 3);
+ const windowText = lines.slice(start, end).join('\n');
+ const containsOriginalText = typeof op.originalText === 'string' && windowText.includes(op.originalText);
+ return {
+ ...hint,
+ status: containsOriginalText ? 'ok' : 'text_not_found_near_hint',
+ relativeFile,
+ excerpt: lines.slice(start, end).map((text, index) => ({
+ line: start + index + 1,
+ text: text.slice(0, 240),
+ })),
+ };
+}
+
+function normalizeSourceHint(hint) {
+ if (!hint || typeof hint !== 'object') return {};
+ let line = Number.isFinite(Number(hint.line)) ? Number(hint.line) : null;
+ let column = Number.isFinite(Number(hint.column)) ? Number(hint.column) : null;
+ if ((!line || !column) && typeof hint.loc === 'string') {
+ const match = hint.loc.match(/^(\d+)(?::(\d+))?/);
+ if (match) {
+ line = Number(match[1]);
+ if (match[2]) column = Number(match[2]);
+ }
+ }
+ return {
+ file: typeof hint.file === 'string' ? hint.file : '',
+ loc: typeof hint.loc === 'string' ? hint.loc : '',
+ line,
+ column,
+ };
+}
+
+function collectSearchFiles(cwd) {
+ const out = [];
+ const seenDirs = new Set();
+ const seenFiles = new Set();
+ for (const dir of SEARCH_DIRS) {
+ scanDir(path.join(cwd, dir), cwd, seenDirs, seenFiles, out, 0);
+ }
+ scanRootFiles(cwd, seenFiles, out);
+ return out;
+}
+
+function scanDir(dir, cwd, seenDirs, seenFiles, out, depth) {
+ if (depth > 7 || !fs.existsSync(dir)) return;
+ let realDir;
+ try { realDir = fs.realpathSync(dir); } catch { return; }
+ if (seenDirs.has(realDir)) return;
+ seenDirs.add(realDir);
+
+ let entries;
+ try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return; }
+ for (const entry of entries) {
+ const fullPath = path.join(dir, entry.name);
+ if (entry.isDirectory()) {
+ if (SKIP_DIRS.has(entry.name)) continue;
+ scanDir(fullPath, cwd, seenDirs, seenFiles, out, depth + 1);
+ continue;
+ }
+ if (!entry.isFile() || !TEXT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue;
+ maybeAddSearchFile(fullPath, cwd, seenFiles, out);
+ }
+}
+
+function scanRootFiles(cwd, seenFiles, out) {
+ let entries;
+ try { entries = fs.readdirSync(cwd, { withFileTypes: true }); } catch { return; }
+ for (const entry of entries) {
+ if (!entry.isFile() || !TEXT_EXTENSIONS.has(path.extname(entry.name).toLowerCase())) continue;
+ maybeAddSearchFile(path.join(cwd, entry.name), cwd, seenFiles, out);
+ }
+}
+
+function maybeAddSearchFile(file, cwd, seenFiles, out) {
+ let realFile;
+ try { realFile = fs.realpathSync(file); } catch { return; }
+ if (seenFiles.has(realFile)) return;
+ seenFiles.add(realFile);
+ if (isGeneratedFile(file, { cwd })) return;
+ let content;
+ try { content = fs.readFileSync(file, 'utf-8'); } catch { return; }
+ out.push({ file, relativeFile: path.relative(cwd, file), content, lines: content.split('\n') });
+}
+
+function findLiteralMatches(searchFiles, needle, { max }) {
+ return findMatches(searchFiles, needle, { kind: 'text', max });
+}
+
+function findObjectKeyMatches(searchFiles, text, { max }) {
+ const re = new RegExp('(["\\\'`])' + escapeRegExp(text) + '\\1(?=\\s*:)', 'g');
+ const out = [];
+ for (const file of searchFiles) {
+ for (const match of file.content.matchAll(re)) {
+ out.push(matchForIndex(file, match.index, 'object_key', text));
+ if (out.length >= max) return out;
+ }
+ }
+ return out;
+}
+
+function findLocatorMatches(searchFiles, op, { max }) {
+ const needles = [];
+ if (op.elementId) needles.push({ kind: 'id', needle: op.elementId });
+ for (const cls of op.classes || []) {
+ if (cls) needles.push({ kind: 'class', needle: cls });
+ }
+ if (op.tag) needles.push({ kind: 'tag', needle: '<' + op.tag });
+
+ const out = [];
+ const seen = new Set();
+ for (const { kind, needle } of needles) {
+ for (const match of findMatches(searchFiles, needle, { kind, max })) {
+ const key = match.file + ':' + match.line + ':' + kind + ':' + needle;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push({ ...match, needle });
+ if (out.length >= max) return out;
+ }
+ }
+ return out;
+}
+
+function findContextMatches(searchFiles, hints, { maxPerHint, max }) {
+ const out = [];
+ const seen = new Set();
+ for (const hint of hints || []) {
+ for (const match of findMatches(searchFiles, hint, { kind: 'context', max: maxPerHint })) {
+ const key = match.file + ':' + match.line + ':' + hint;
+ if (seen.has(key)) continue;
+ seen.add(key);
+ out.push({ ...match, needle: hint });
+ if (out.length >= max) return out;
+ }
+ }
+ return out;
+}
+
+function findMatches(searchFiles, needle, { kind, max }) {
+ const text = String(needle || '');
+ if (!text) return [];
+ const out = [];
+ for (const file of searchFiles) {
+ let index = 0;
+ while (out.length < max) {
+ index = file.content.indexOf(text, index);
+ if (index === -1) break;
+ out.push(matchForIndex(file, index, kind, text));
+ index += Math.max(1, text.length);
+ }
+ if (out.length >= max) break;
+ }
+ return out;
+}
+
+function matchForIndex(file, index, kind, needle) {
+ const line = file.content.slice(0, index).split('\n').length;
+ const lineText = file.lines[line - 1] || '';
+ return {
+ kind,
+ file: file.relativeFile,
+ line,
+ needle,
+ excerpt: lineText.trim().slice(0, 240),
+ };
+}
+
+function isPathInsideOrEqual(cwd, file) {
+ const rel = path.relative(path.resolve(cwd), path.resolve(file));
+ return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel));
+}
+
+function normalizeText(value) {
+ return String(value || '').replace(/\s+/g, ' ').trim();
+}
+
+function decodeBasicHtml(value) {
+ return value
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/'/g, "'")
+ .replace(/&/g, '&')
+ .replace(/</g, '<')
+ .replace(/>/g, '>');
+}
+
+function escapeRegExp(value) {
+ return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+}
diff --git a/.claude/skills/impeccable/scripts/live-manual-edits-buffer.mjs b/.claude/skills/impeccable/scripts/live-manual-edits-buffer.mjs
new file mode 100644
index 0000000..9e3dcf4
--- /dev/null
+++ b/.claude/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 './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/.claude/skills/impeccable/scripts/live-poll.mjs b/.claude/skills/impeccable/scripts/live-poll.mjs
new file mode 100644
index 0000000..fad8366
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/live-poll.mjs
@@ -0,0 +1,378 @@
+/**
+ * CLI client for the live variant mode poll/reply protocol.
+ *
+ * Usage:
+ * npx impeccable poll # Block until browser event, print JSON
+ * npx impeccable poll --stream # Experimental: keep polling; one JSON line per event
+ * npx impeccable poll --timeout=600000 # Custom timeout (ms); default is long-poll friendly
+ * npx impeccable poll --reply done # Reply "done" to event
+ * npx impeccable poll --reply error "msg" # Reply with error
+ */
+
+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';
+
+// 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
+// that ceiling and loop in `pollOnce` to synthesize a long poll without
+// depending on the standalone undici package.
+export const PER_REQUEST_TIMEOUT_MS = 270_000;
+
+const EVENT_TYPES_NEEDING_AGENT_REPLY = new Set(['generate', 'steer', 'manual_edit_apply']);
+
+function readServerInfo() {
+ const record = readLiveServerInfo(process.cwd());
+ if (!record) {
+ console.error('No running live server found. Start one with: npx impeccable live');
+ process.exit(1);
+ }
+ return record.info;
+}
+
+export function buildPollReplyPayload(token, { id, type, message, file, data }) {
+ return { token, id, type, message, file, data };
+}
+
+export function manualApplyPollBanner(event = {}) {
+ const id = event.id || 'EVENT_ID';
+ return [
+ `Manual Apply action required: edit source, then reply with \`live-poll.mjs --reply ${id} done --data ''\`.`,
+ 'The JSON data must include status, appliedEntryIds, failed, files, and notes; summary counters are only a recovery fallback.',
+ 'Do not run live-commit-manual-edits.mjs for this leased event.',
+ 'Do not poll again before replying.',
+ ].join('\n') + '\n';
+}
+
+/**
+ * Parse `--reply [--file path] [--data ''] [message]` argv
+ * into a reply object. Returns null when `--reply` is absent. Throws (code
+ * INVALID_REPLY_ARGS) when the reply shape is missing its event id/status and
+ * INVALID_DATA_JSON when `--data` is present but not valid JSON.
+ */
+export function parseReplyArgs(args) {
+ const replyIdx = args.indexOf('--reply');
+ if (replyIdx === -1) return null;
+ const id = args[replyIdx + 1];
+ const status = args[replyIdx + 2];
+ validateReplyArgs({ id, status });
+ const fileIdx = args.indexOf('--file');
+ const file = fileIdx !== -1 && fileIdx + 1 < args.length ? args[fileIdx + 1] : undefined;
+ const dataIdx = args.indexOf('--data');
+ let data;
+ if (dataIdx !== -1 && dataIdx + 1 < args.length) {
+ try {
+ data = JSON.parse(args[dataIdx + 1]);
+ } catch (err) {
+ const wrapped = new Error('--data must be valid JSON: ' + err.message);
+ wrapped.code = 'INVALID_DATA_JSON';
+ throw wrapped;
+ }
+ }
+ const message = args.find((a, i) =>
+ i > replyIdx + 2
+ && !a.startsWith('--')
+ && i !== fileIdx + 1
+ && i !== dataIdx + 1
+ ) || undefined;
+ return { id, type: status, message, file, data };
+}
+
+function validateReplyArgs({ id, status }) {
+ const usage = "Usage: npx impeccable poll --reply [--file path] [--data ''] [message]";
+ if (!id || id.startsWith('--')) {
+ const err = new Error(`${usage}\nMissing event id after --reply.`);
+ err.code = 'INVALID_REPLY_ARGS';
+ throw err;
+ }
+ if (['done', 'error', 'complete', 'discard', 'discarded'].includes(id)) {
+ const err = new Error(`${usage}\nThe value after --reply must be the event id, not the status ${JSON.stringify(id)}. Use --reply EVENT_ID ${id}.`);
+ err.code = 'INVALID_REPLY_ARGS';
+ throw err;
+ }
+ if (!status || status.startsWith('--')) {
+ const err = new Error(`${usage}\nMissing reply status after event id ${JSON.stringify(id)}.`);
+ err.code = 'INVALID_REPLY_ARGS';
+ throw err;
+ }
+}
+
+export function requiresAgentReply(event) {
+ return EVENT_TYPES_NEEDING_AGENT_REPLY.has(event?.type);
+}
+
+export async function postReply(base, token, reply) {
+ const res = await fetch(`${base}/poll`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(buildPollReplyPayload(token, reply)),
+ });
+ if (!res.ok) {
+ const body = await res.json().catch(() => ({}));
+ const parts = [body.error || res.statusText, body.reason, body.hint].filter(Boolean);
+ throw new Error(parts.join(': '));
+ }
+}
+
+export async function fetchServerStatus(base, token) {
+ const res = await fetch(`${base}/status?token=${token}`);
+ if (res.status === 401) {
+ const err = new Error('Authentication failed. The server token may have changed.');
+ err.code = 'AUTH_FAILED';
+ throw err;
+ }
+ if (!res.ok) {
+ throw new Error(`Status failed: ${res.status} ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export function isEventPending(status, eventId) {
+ return (status.pendingEvents || []).some((entry) => entry.id === eventId);
+}
+
+export async function waitForEventAck(base, token, eventId, {
+ pollIntervalMs = 400,
+ maxWaitMs = 600_000,
+} = {}) {
+ const deadline = Date.now() + maxWaitMs;
+ while (Date.now() < deadline) {
+ const status = await fetchServerStatus(base, token);
+ if (!isEventPending(status, eventId)) return true;
+ await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
+ }
+ return false;
+}
+
+export async function fetchNextEvent(base, token, { totalDeadline } = {}) {
+ while (true) {
+ if (totalDeadline && Date.now() >= totalDeadline) {
+ return { type: 'timeout' };
+ }
+
+ const remaining = totalDeadline
+ ? totalDeadline - Date.now()
+ : PER_REQUEST_TIMEOUT_MS;
+ const slice = Math.min(Math.max(remaining, 1000), PER_REQUEST_TIMEOUT_MS);
+ const res = await fetch(`${base}/poll?token=${token}&timeout=${slice}`);
+
+ if (res.status === 401) {
+ const err = new Error('Authentication failed. The server token may have changed.');
+ err.code = 'AUTH_FAILED';
+ throw err;
+ }
+
+ if (!res.ok) {
+ throw new Error(`Poll failed: ${res.status} ${res.statusText}`);
+ }
+
+ const next = await res.json();
+ if (next?.type === 'timeout') {
+ if (totalDeadline && Date.now() < totalDeadline) continue;
+ if (!totalDeadline) continue;
+ return next;
+ }
+ return next;
+ }
+}
+
+export async function augmentEventWithAcceptHandling(event, base, token) {
+ if (event.type !== 'accept' && event.type !== 'discard') return event;
+
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
+ const acceptScript = path.join(__dirname, 'live-accept.mjs');
+ const scriptArgs = buildAcceptScriptArgs(event);
+
+ try {
+ const out = execFileSync(
+ 'node',
+ [acceptScript, ...scriptArgs],
+ { encoding: 'utf-8', cwd: process.cwd(), timeout: 30_000 },
+ );
+ event._acceptResult = JSON.parse(out.trim());
+ } catch (err) {
+ event._acceptResult = { handled: false, mode: 'error', error: err.message };
+ }
+
+ const completionType = completionTypeForAcceptResult(event.type, event._acceptResult);
+ try {
+ await postReply(base, token, {
+ id: event.id,
+ type: completionType,
+ message: event._acceptResult?.error,
+ file: event._acceptResult?.file,
+ data: event._acceptResult?.carbonize === true ? { carbonize: true } : undefined,
+ });
+ } catch (err) {
+ event._completionAck = { ok: false, error: err.message };
+ }
+ if (!event._completionAck) {
+ event._completionAck = completionAckForAcceptResult(event.id, completionType, event._acceptResult);
+ }
+
+ return event;
+}
+
+export function buildAcceptScriptArgs(event) {
+ const scriptArgs = event.type === 'discard'
+ ? ['--id', String(event.id), '--discard']
+ : ['--id', String(event.id), '--variant', String(event.variantId)];
+ if (event.pageUrl) scriptArgs.push('--page-url', String(event.pageUrl));
+ if (event.type === 'accept' && event.paramValues && Object.keys(event.paramValues).length > 0) {
+ scriptArgs.push('--param-values', JSON.stringify(event.paramValues));
+ }
+ return scriptArgs;
+}
+
+export function writeCarbonizeBanner(event) {
+ if (event.type === 'manual_edit_apply') {
+ process.stderr.write('\n' + manualApplyPollBanner(event) + '\n');
+ }
+ if (event._acceptResult?.carbonize === true) {
+ process.stderr.write('\n⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id ' + event.id + '. See reference/live.md "Required after accept".\n\n');
+ }
+}
+
+export function printPollEvent(event) {
+ console.log(JSON.stringify(event));
+}
+
+export async function runPollOnce(base, token, { totalTimeout = 600_000 } = {}) {
+ const deadline = Date.now() + totalTimeout;
+ const event = await fetchNextEvent(base, token, { totalDeadline: deadline });
+ await augmentEventWithAcceptHandling(event, base, token);
+ writeCarbonizeBanner(event);
+ printPollEvent(event);
+ return event;
+}
+
+export async function runPollStream(base, token, {
+ ackTimeoutMs = 600_000,
+ ackPollIntervalMs = 400,
+ shouldContinue = () => true,
+} = {}) {
+ process.stderr.write('[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running\n');
+
+ while (shouldContinue()) {
+ const event = await fetchNextEvent(base, token);
+ await augmentEventWithAcceptHandling(event, base, token);
+ writeCarbonizeBanner(event);
+ printPollEvent(event);
+
+ if (event.type === 'exit') return event;
+
+ if (requiresAgentReply(event)) {
+ const acked = await waitForEventAck(base, token, event.id, {
+ pollIntervalMs: ackPollIntervalMs,
+ maxWaitMs: ackTimeoutMs,
+ });
+ if (!acked) {
+ const err = new Error(`Timed out waiting for --reply on event ${event.id}`);
+ err.code = 'ACK_TIMEOUT';
+ throw err;
+ }
+ }
+ }
+
+ return null;
+}
+
+function handlePollError(err) {
+ if (err.code === 'AUTH_FAILED') {
+ console.error(err.message);
+ console.error('Try restarting: npx impeccable live stop && npx impeccable live');
+ process.exit(1);
+ }
+ if (err.cause?.code === 'ECONNREFUSED') {
+ console.error('Live server not running. Start one with: npx impeccable live');
+ process.exit(1);
+ }
+ if (err.code === 'ACK_TIMEOUT') {
+ console.error(err.message);
+ process.exit(1);
+ }
+ console.error('Poll failed:', err.message);
+ process.exit(1);
+}
+
+export async function pollCli() {
+ const args = process.argv.slice(2);
+
+ if (args.includes('--help') || args.includes('-h')) {
+ console.log(`Usage: impeccable poll [options]
+
+Wait for a browser event from the live variant server, or reply to one.
+
+Modes:
+ poll Block until a browser event arrives, print JSON, exit
+ poll --stream Keep polling; print one JSON line per event (see live.md)
+ poll --reply done Reply "done" to event (replace or insert generate)
+ poll --reply steer_done Reply after handling a steer event (unlocks Steer bar)
+ poll --reply error "msg" Reply with an error message
+ poll --reply done --data ''
+ Reply with a structured JSON result (manual_edit_apply)
+
+Options:
+ --timeout=MS One-shot poll timeout in ms (default: 600000). Ignored in --stream mode
+ --ack-timeout=MS Stream mode: max wait for --reply after generate/steer (default: 600000)
+ --file PATH Attach a source file path to the reply (generate flow)
+ --data JSON Attach a JSON result object to the reply (manual_edit_apply flow). Must be valid JSON
+ --help Show this help message
+
+Harness note:
+ Default one-shot mode is the portable contract for Claude Code, Codex, and Cursor.
+ --stream is experimental for harnesses with fast incremental stdout; do not use on Cursor.`);
+ process.exit(0);
+ }
+
+ const info = readServerInfo();
+ const base = `http://localhost:${info.port}`;
+
+ // Reply mode: npx impeccable poll --reply [--file path] [--data ''] [message]
+ if (args.includes('--reply')) {
+ let reply;
+ try {
+ reply = parseReplyArgs(args);
+ } catch (err) {
+ console.error(err.message);
+ process.exit(1);
+ }
+
+ try {
+ await postReply(base, info.token, reply);
+ } catch (err) {
+ if (err.cause?.code === 'ECONNREFUSED') {
+ console.error('Live server not running. Start one with: npx impeccable live');
+ } else {
+ console.error('Reply failed:', err.message);
+ }
+ process.exit(1);
+ }
+ return;
+ }
+
+ const streamMode = args.includes('--stream');
+ const ackTimeoutArg = args.find((a) => a.startsWith('--ack-timeout='));
+ const ackTimeoutMs = ackTimeoutArg ? parseInt(ackTimeoutArg.split('=')[1], 10) : 600_000;
+
+ try {
+ if (streamMode) {
+ await runPollStream(base, info.token, { ackTimeoutMs });
+ return;
+ }
+
+ const timeoutArg = args.find((a) => a.startsWith('--timeout='));
+ const totalTimeout = timeoutArg ? parseInt(timeoutArg.split('=')[1], 10) : 600_000;
+ await runPollOnce(base, info.token, { totalTimeout });
+ } catch (err) {
+ handlePollError(err);
+ }
+}
+
+// Auto-execute when run directly
+const _running = process.argv[1];
+if (_running?.endsWith('live-poll.mjs') || _running?.endsWith('live-poll.mjs/')) {
+ pollCli();
+}
diff --git a/.claude/skills/impeccable/scripts/live-resume.mjs b/.claude/skills/impeccable/scripts/live-resume.mjs
new file mode 100644
index 0000000..e54831f
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/live-resume.mjs
@@ -0,0 +1,94 @@
+#!/usr/bin/env node
+/**
+ * Recover the next agent action from the durable live-session journal.
+ */
+
+import { createLiveSessionStore } from './live-session-store.mjs';
+
+function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
+ const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
+ return `live-poll.mjs --reply ${id} done --data ''`;
+}
+
+export function manualApplyResumeHint(event = {}) {
+ const summary = event.manualApplySummary || summarizeManualApplyEvent(event);
+ const parts = [];
+ if (summary.pageUrl) parts.push(`page ${summary.pageUrl}`);
+ if (summary.chunk) parts.push(`chunk ${summary.chunk.index}/${summary.chunk.total}`);
+ if (Number.isFinite(summary.opCount)) parts.push(`${summary.opCount} op(s)`);
+ if (Number.isFinite(summary.entryCount)) parts.push(`${summary.entryCount} entr${summary.entryCount === 1 ? 'y' : 'ies'}`);
+ if (summary.files?.length) parts.push(`likely files: ${summary.files.join(', ')}`);
+ const scope = parts.length ? ` (${parts.join(', ')})` : '';
+ return `Manual Apply pending${scope}. If you have not already leased it, run live-poll.mjs. Apply the source edits from the manual_edit_apply batch, then reply with ${manualApplyReplyCommand(event.id)}. Polling only leases this work item; it does not commit source edits. Do not run live-commit-manual-edits.mjs for this leased event. Do not poll again before replying.`;
+}
+
+function summarizeManualApplyEvent(event = {}) {
+ const entries = Array.isArray(event.batch?.entries) ? event.batch.entries : [];
+ const opCount = entries.reduce((sum, entry) => sum + (Array.isArray(entry.ops) ? entry.ops.length : 0), 0);
+ return {
+ pageUrl: event.pageUrl || null,
+ chunk: event.chunk || null,
+ entryCount: entries.length,
+ opCount,
+ files: collectManualApplyFiles(event.batch),
+ };
+}
+
+function collectManualApplyFiles(batch) {
+ const files = [];
+ for (const entry of batch?.entries || []) {
+ for (const op of entry.ops || []) files.push(op.sourceHint?.file);
+ }
+ for (const candidate of batch?.candidates || []) {
+ files.push(candidate.sourceHint?.relativeFile, candidate.sourceHint?.file);
+ for (const item of candidate.textMatches || []) files.push(item.file);
+ for (const item of candidate.objectKeyMatches || []) files.push(item.file);
+ for (const item of candidate.locatorMatches || []) files.push(item.file);
+ for (const item of candidate.contextTextMatches || []) files.push(item.file);
+ }
+ return [...new Set(files.filter((file) => typeof file === 'string' && file.length > 0))].sort();
+}
+
+function parseArgs(argv) {
+ const out = { id: null };
+ for (let i = 0; i < argv.length; i++) {
+ const arg = argv[i];
+ if (arg === '--id') out.id = argv[++i];
+ else if (arg.startsWith('--id=')) out.id = arg.slice('--id='.length);
+ else if (arg === '--help' || arg === '-h') out.help = true;
+ }
+ return out;
+}
+
+export async function resumeCli() {
+ const args = parseArgs(process.argv.slice(2));
+ if (args.help) {
+ console.log(`Usage: node live-resume.mjs [--id SESSION_ID]\n\nPrint the active durable session checkpoint and the next safe agent action.`);
+ return;
+ }
+
+ const store = createLiveSessionStore({ cwd: process.cwd(), sessionId: args.id || undefined });
+ const snapshot = args.id ? store.getSnapshot(args.id) : store.listActiveSessions()[0] || null;
+ if (!snapshot) {
+ console.log(JSON.stringify({ active: false, nextAction: 'No active durable live session found.' }, null, 2));
+ return;
+ }
+
+ const pending = snapshot.pendingEvent || null;
+ const nextAction = pending
+ ? pending.type === 'manual_edit_apply'
+ ? manualApplyResumeHint(pending)
+ : `Run live-poll.mjs, handle ${pending.type} ${pending.id}, then acknowledge with live-poll.mjs --reply ${pending.id} done.`
+ : snapshot.phase === 'carbonize_required'
+ ? `Finish carbonize cleanup${snapshot.sourceFile ? ` in ${snapshot.sourceFile}` : ''}, then run live-complete.mjs --id ${snapshot.id}.`
+ : snapshot.phase === 'accept_requested'
+ ? `Run live-complete.mjs --id ${snapshot.id} after verifying the accepted variant is written.`
+ : `Inspect ${snapshot.id}; no pending agent event is currently queued.`;
+
+ console.log(JSON.stringify({ active: true, snapshot, pendingEvent: pending, nextAction }, null, 2));
+}
+
+const _running = process.argv[1];
+if (_running?.endsWith('live-resume.mjs') || _running?.endsWith('live-resume.mjs/')) {
+ resumeCli();
+}
diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs
new file mode 100644
index 0000000..16c8285
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/live-server.mjs
@@ -0,0 +1,2190 @@
+#!/usr/bin/env node
+/**
+ * Live variant mode server (self-contained, zero dependencies).
+ *
+ * Serves the browser script (/live.js), the detection overlay (/detect.js),
+ * uses Server-Sent Events (SSE) for server→browser push, and HTTP POST for
+ * browser→server events. Agent communicates via HTTP long-poll (/poll).
+ *
+ * Usage:
+ * node /live-server.mjs # start
+ * node /live-server.mjs stop # stop + remove injected live.js tag
+ * node /live-server.mjs stop --keep-inject # stop only
+ * node /live-server.mjs --help
+ */
+
+import http from 'node:http';
+import { randomUUID } from 'node:crypto';
+import { spawn, execFileSync } from 'node:child_process';
+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 { resolveContextDir } from './context.mjs';
+import { createLiveSessionStore } from './live-session-store.mjs';
+import { validateEvent } from './live-event-validation.mjs';
+import {
+ getDesignSidecarPath,
+ getLiveDir,
+ getLiveAnnotationsDir,
+ readLiveServerInfo,
+ removeLiveServerInfo,
+ resolveDesignSidecarPath,
+ writeLiveServerInfo,
+} from './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';
+import { buildManualEditEvidence } from './live-manual-edit-evidence.mjs';
+import { commitManualEdits } from './live-commit-manual-edits.mjs';
+
+const __dirname = path.dirname(fileURLToPath(import.meta.url));
+// PRODUCT.md / DESIGN.md live wherever context.mjs resolves. The generated
+// DESIGN sidecar is project-local at .impeccable/design.json, with legacy
+// DESIGN.json fallback for existing projects.
+const CONTEXT_DIR = resolveContextDir(process.cwd());
+const DEFAULT_POLL_TIMEOUT = 600_000; // 10 min — agent re-polls on timeout anyway
+const SSE_HEARTBEAT_INTERVAL = 30_000; // keepalive ping every 30s
+
+// ---------------------------------------------------------------------------
+// Port detection
+// ---------------------------------------------------------------------------
+
+async function findOpenPort(start = 8400) {
+ return new Promise((resolve) => {
+ const srv = net.createServer();
+ srv.listen(start, '127.0.0.1', () => {
+ const port = srv.address().port;
+ srv.close(() => resolve(port));
+ });
+ srv.on('error', () => resolve(findOpenPort(start + 1)));
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Session state
+// ---------------------------------------------------------------------------
+
+const state = {
+ token: null,
+ port: null,
+ sseClients: new Set(), // SSE response objects (server→browser push)
+ pendingEvents: [], // browser events waiting for agent ack ({ event, leaseUntil })
+ pendingPolls: [], // agent poll callbacks waiting for browser events
+ nextEventSeq: 1,
+ lastAgentPollingBroadcast: null,
+ exitTimer: null,
+ sessionDir: null, // per-session tmp dir for annotation screenshots
+ sessionStore: null,
+ leaseTimer: null,
+ manualEditActivity: null,
+ nextManualEditSeq: 1,
+ // Deferreds for in-flight chat-routed Apply events. Keyed by event id; each
+ // entry is resolved when the chat agent POSTs an ack carrying the batch
+ // result, or rejected when the hard timeout fires.
+ pendingApplyDeferreds: new Map(),
+ // Updated whenever a /poll long-poll request arrives or is resolved with an
+ // event. Used to detect "a chat agent is likely attached" without requiring
+ // a poll to be parked at the exact moment we dispatch.
+ lastPollAt: 0,
+ timedOutApplyIds: new Map(),
+};
+
+const CHAT_POLL_FRESHNESS_MS = 60_000;
+const APPLY_EVENT_HARD_TIMEOUT_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_HARD_TIMEOUT_MS || 150_000);
+const APPLY_EVENT_SOFT_DEADLINE_MS = Number(process.env.IMPECCABLE_LIVE_APPLY_EVENT_SOFT_DEADLINE_MS || 120_000);
+const DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE = 3;
+const MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE = 1;
+const MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE = 20;
+const MANUAL_APPLY_COMPACT_TEXT_LIMIT = 240;
+const MANUAL_APPLY_COMPACT_NEARBY_LIMIT = 4;
+const DEBUG_MANUAL_EDIT_EVENTS = /^(1|true|yes)$/i.test(process.env.IMPECCABLE_LIVE_DEBUG_EVENTS || '');
+
+function tombstoneTimedOutApplyId(eventId, details = {}) {
+ if (!eventId) return;
+ state.timedOutApplyIds.set(eventId, details);
+ if (state.timedOutApplyIds.size <= 200) return;
+ const oldest = state.timedOutApplyIds.keys().next().value;
+ state.timedOutApplyIds.delete(oldest);
+}
+
+function chatAgentLikelyActive() {
+ if (state.pendingPolls.length > 0) return true;
+ if (!state.lastPollAt) return false;
+ return Date.now() - state.lastPollAt < CHAT_POLL_FRESHNESS_MS;
+}
+
+function manualEditApplyChunkSize(env = process.env) {
+ const raw = Number(env.IMPECCABLE_LIVE_MANUAL_EDIT_CHUNK_SIZE);
+ if (!Number.isFinite(raw)) return DEFAULT_MANUAL_EDIT_APPLY_CHUNK_SIZE;
+ const size = Math.trunc(raw);
+ return Math.max(MIN_MANUAL_EDIT_APPLY_CHUNK_SIZE, Math.min(MAX_MANUAL_EDIT_APPLY_CHUNK_SIZE, size));
+}
+
+function countManualApplyOps(entriesOrBatch) {
+ const entries = Array.isArray(entriesOrBatch)
+ ? entriesOrBatch
+ : Array.isArray(entriesOrBatch?.entries) ? entriesOrBatch.entries : [];
+ let count = 0;
+ for (const entry of entries) count += Array.isArray(entry.ops) ? entry.ops.length : 0;
+ return count;
+}
+
+function pushApplyEventAndWait(batch, pageUrl, chunk = null, repair = null) {
+ const eventId = randomUUID().replace(/-/g, '').slice(0, 8);
+ const evidencePath = writeManualApplyEvidence(eventId, batch);
+ const event = {
+ type: 'manual_edit_apply',
+ id: eventId,
+ pageUrl,
+ batch: compactManualApplyBatch(batch),
+ evidencePath,
+ agentAction: buildManualApplyAgentAction(eventId),
+ schemaVersion: 1,
+ deadlineMs: APPLY_EVENT_SOFT_DEADLINE_MS,
+ };
+ if (chunk) event.chunk = chunk;
+ if (repair) event.repair = repair;
+ const rollbackSnapshot = snapshotApplyEventFiles(batch);
+ recordManualEditActivity('manual_edit_apply_dispatched', {
+ id: eventId,
+ pageUrl,
+ chunk,
+ repair,
+ entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
+ opCount: countManualApplyOps(batch),
+ fileCount: collectManualApplyFiles(batch).length,
+ });
+ return new Promise((resolve, reject) => {
+ const timer = setTimeout(() => {
+ state.pendingApplyDeferreds.delete(eventId);
+ tombstoneTimedOutApplyId(eventId, { batch, rollbackSnapshot });
+ acknowledgePendingEvent(eventId);
+ removeManualApplyEvidence(evidencePath);
+ recordManualEditActivity('manual_edit_apply_timeout', {
+ id: eventId,
+ pageUrl,
+ chunk,
+ entryCount: Array.isArray(batch.entries) ? batch.entries.length : 0,
+ opCount: countManualApplyOps(batch),
+ });
+ reject(new Error('chat_agent_timeout'));
+ }, APPLY_EVENT_HARD_TIMEOUT_MS);
+ state.pendingApplyDeferreds.set(eventId, { resolve, reject, timer, event, batch, pageUrl, rollbackSnapshot });
+ enqueueEvent(event);
+ });
+}
+
+function writeManualApplyEvidence(eventId, batch) {
+ const dir = manualApplyEvidenceDir(process.cwd());
+ fs.mkdirSync(dir, { recursive: true });
+ const evidencePath = path.join(dir, `${eventId}.json`);
+ fs.writeFileSync(evidencePath, JSON.stringify(batch, null, 2) + '\n', 'utf-8');
+ return evidencePath;
+}
+
+function manualApplyEvidenceDir(cwd = process.cwd()) {
+ return path.join(getLiveDir(cwd), 'manual-edit-evidence');
+}
+
+function normalizeManualApplyEvidencePath(evidencePath, cwd = process.cwd()) {
+ if (!evidencePath || typeof evidencePath !== 'string') return null;
+ const fullPath = path.isAbsolute(evidencePath) ? evidencePath : path.resolve(cwd, evidencePath);
+ const evidenceDir = manualApplyEvidenceDir(cwd);
+ const relative = path.relative(evidenceDir, fullPath);
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
+ if (path.extname(relative) !== '.json') return null;
+ return fullPath;
+}
+
+function removeManualApplyEvidence(evidencePath, cwd = process.cwd()) {
+ const fullPath = normalizeManualApplyEvidencePath(evidencePath, cwd);
+ if (!fullPath) return false;
+ try {
+ fs.unlinkSync(fullPath);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function referencedManualApplyEvidencePaths(cwd = process.cwd()) {
+ const referenced = new Set();
+ const add = (event) => {
+ const fullPath = normalizeManualApplyEvidencePath(event?.evidencePath, cwd);
+ if (fullPath) referenced.add(fullPath);
+ };
+ for (const entry of state.pendingEvents) add(entry.event);
+ for (const deferred of state.pendingApplyDeferreds.values()) add(deferred.event);
+ return referenced;
+}
+
+function pruneStaleManualApplyEvidence(cwd = process.cwd()) {
+ const dir = manualApplyEvidenceDir(cwd);
+ if (!fs.existsSync(dir)) return [];
+ const referenced = referencedManualApplyEvidencePaths(cwd);
+ const removed = [];
+ for (const name of fs.readdirSync(dir)) {
+ if (!name.endsWith('.json')) continue;
+ const fullPath = path.join(dir, name);
+ if (referenced.has(fullPath)) continue;
+ try {
+ fs.unlinkSync(fullPath);
+ removed.push(fullPath);
+ } catch {
+ // Stale evidence cleanup is best-effort; Apply verification never relies
+ // on deleting these files.
+ }
+ }
+ return removed;
+}
+
+function compactManualApplyBatch(batch = {}) {
+ const entries = (batch.entries || []).map(compactManualApplyEntry);
+ const candidates = compactManualApplyCandidates(batch.candidates || []);
+ return {
+ version: batch.version,
+ pageUrl: batch.pageUrl || null,
+ count: batch.count,
+ entries,
+ ops: entries.flatMap((entry) => entry.ops.map((op) => ({ ...op, entryId: entry.id }))),
+ candidates: candidates.length > 0 ? candidates : undefined,
+ context: batch.context ? {
+ bufferPath: batch.context.bufferPath,
+ totalEntries: batch.context.totalEntries,
+ totalOps: batch.context.totalOps,
+ chunkIndex: batch.context.chunkIndex,
+ chunkTotal: batch.context.chunkTotal,
+ totalApplyOps: batch.context.totalApplyOps,
+ } : undefined,
+ };
+}
+
+function compactManualApplyCandidates(candidates) {
+ return (Array.isArray(candidates) ? candidates : [])
+ .slice(0, 24)
+ .map((candidate) => ({
+ entryId: candidate.entryId,
+ ref: candidate.ref,
+ sourceHint: compactManualApplySourceMatch(candidate.sourceHint),
+ textMatches: compactManualApplySourceMatches(candidate.textMatches, 8),
+ objectKeyMatches: compactManualApplySourceMatches(candidate.objectKeyMatches, 8),
+ contextTextMatches: compactManualApplySourceMatches(candidate.contextTextMatches, 8),
+ locatorMatches: compactManualApplySourceMatches(candidate.locatorMatches, 6),
+ }));
+}
+
+function compactManualApplySourceMatches(matches, limit) {
+ return (Array.isArray(matches) ? matches : [])
+ .slice(0, limit)
+ .map(compactManualApplySourceMatch)
+ .filter(Boolean);
+}
+
+function compactManualApplySourceMatch(match) {
+ if (!match || typeof match !== 'object') return null;
+ const file = match.relativeFile || match.file;
+ if (!file && !match.line) return null;
+ return {
+ file: summarizeManualLogFile(file),
+ line: match.line || null,
+ column: match.column || null,
+ reason: match.reason || match.kind || undefined,
+ status: match.status || undefined,
+ };
+}
+
+function compactManualApplyEntry(entry = {}) {
+ return {
+ id: entry.id,
+ pageUrl: entry.pageUrl,
+ stagedAt: entry.stagedAt || null,
+ element: compactManualApplyContext(entry.element),
+ ops: (entry.ops || []).map(compactManualApplyOp),
+ };
+}
+
+function compactManualApplyOp(op = {}) {
+ return {
+ entryId: op.entryId,
+ ref: op.ref,
+ contextRef: op.contextRef,
+ tag: op.tag,
+ elementId: op.elementId,
+ classes: Array.isArray(op.classes) ? op.classes : [],
+ originalText: op.originalText,
+ newText: op.newText,
+ deleted: op.deleted === true || undefined,
+ sourceHint: op.sourceHint || null,
+ leaf: compactManualApplyContext(op.leaf),
+ nearbyEditableTexts: compactNearbyManualEditTexts(op.nearbyEditableTexts),
+ container: compactManualApplyContext(op.container),
+ contextHints: Array.isArray(op.contextHints) ? op.contextHints.slice(0, 8) : undefined,
+ };
+}
+
+function compactManualApplyContext(value) {
+ if (!value || typeof value !== 'object') return null;
+ return {
+ ref: value.ref,
+ tagName: value.tagName || value.tag || null,
+ id: value.id || null,
+ classes: Array.isArray(value.classes) ? value.classes : [],
+ textContent: truncateManualApplyText(value.textContent, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
+ };
+}
+
+function compactNearbyManualEditTexts(items) {
+ return (Array.isArray(items) ? items : [])
+ .slice(0, MANUAL_APPLY_COMPACT_NEARBY_LIMIT)
+ .map((item) => typeof item === 'string' ? { text: truncateManualApplyText(item, MANUAL_APPLY_COMPACT_TEXT_LIMIT) } : {
+ ref: item?.ref,
+ tag: item?.tag,
+ classes: Array.isArray(item?.classes) ? item.classes : [],
+ text: truncateManualApplyText(item?.text, MANUAL_APPLY_COMPACT_TEXT_LIMIT),
+ });
+}
+
+function truncateManualApplyText(value, max) {
+ if (typeof value !== 'string') return value || null;
+ return value.length > max ? value.slice(0, max) : value;
+}
+
+async function pushApplyBatchInChunksAndWait(batch, pageUrl, context = {}) {
+ const repair = context?.repair || batch?.repair || null;
+ if (repair) return pushApplyEventAndWait(batch, pageUrl, null, repair);
+ const chunks = splitManualApplyBatch(batch, manualEditApplyChunkSize());
+ if (chunks.length <= 1) return pushApplyEventAndWait(batch, pageUrl);
+
+ const expectedOpsByEntry = new Map();
+ for (const entry of batch?.entries || []) {
+ expectedOpsByEntry.set(entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0);
+ }
+
+ const appliedOpsByEntry = new Map();
+ const failedByEntry = new Map();
+ const files = new Set();
+ const notes = [];
+ let aborted = false;
+
+ for (const chunk of chunks) {
+ if (aborted) {
+ markChunkEntriesFailed(failedByEntry, chunk, 'manual_edit_chunk_aborted');
+ continue;
+ }
+
+ let result;
+ try {
+ result = normalizeApplyChunkResult(await pushApplyEventAndWait(chunk.batch, pageUrl, chunk.meta));
+ } catch (err) {
+ markChunkEntriesFailed(failedByEntry, chunk, err.message || 'chat_agent_error');
+ aborted = true;
+ continue;
+ }
+
+ for (const file of result.files) files.add(file);
+ notes.push(...result.notes);
+
+ const chunkFailedIds = new Set();
+ for (const item of result.failed) {
+ const entryId = item.entryId || item.id;
+ if (!entryId) continue;
+ chunkFailedIds.add(entryId);
+ if (!failedByEntry.has(entryId)) {
+ failedByEntry.set(entryId, {
+ entryId,
+ reason: item.reason || item.message || 'failed',
+ candidates: Array.isArray(item.candidates) ? item.candidates : [],
+ });
+ }
+ }
+
+ if (result.status === 'error') {
+ markChunkEntriesFailed(failedByEntry, chunk, result.message || firstFailureReason(result) || 'chat_agent_error');
+ aborted = true;
+ continue;
+ }
+
+ const reportedAppliedIds = new Set(result.appliedEntryIds);
+ for (const entryId of reportedAppliedIds) {
+ if (!chunk.entryIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
+ appliedOpsByEntry.set(entryId, (appliedOpsByEntry.get(entryId) || 0) + (chunk.opCountsByEntry.get(entryId) || 0));
+ }
+
+ for (const entryId of chunk.entryIds) {
+ if (reportedAppliedIds.has(entryId) || chunkFailedIds.has(entryId)) continue;
+ if (!failedByEntry.has(entryId)) {
+ failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
+ }
+ }
+ }
+
+ const appliedEntryIds = [];
+ for (const [entryId, expectedOps] of expectedOpsByEntry.entries()) {
+ if (failedByEntry.has(entryId)) continue;
+ if ((appliedOpsByEntry.get(entryId) || 0) === expectedOps && expectedOps > 0) {
+ appliedEntryIds.push(entryId);
+ } else if (!failedByEntry.has(entryId)) {
+ failedByEntry.set(entryId, { entryId, reason: 'not_reported_applied', candidates: [] });
+ }
+ }
+
+ const failed = [...failedByEntry.values()];
+ return {
+ status: failed.length === 0 ? 'done' : appliedEntryIds.length > 0 ? 'partial' : 'error',
+ appliedEntryIds,
+ failed,
+ files: [...files],
+ notes,
+ };
+}
+
+function normalizeApplyChunkResult(result) {
+ const status = result?.status === 'partial' ? 'partial' : result?.status === 'error' ? 'error' : 'done';
+ return {
+ status,
+ message: typeof result?.message === 'string' ? result.message : null,
+ appliedEntryIds: Array.isArray(result?.appliedEntryIds) ? result.appliedEntryIds.filter((id) => typeof id === 'string') : [],
+ failed: Array.isArray(result?.failed) ? result.failed.filter(Boolean) : [],
+ files: Array.isArray(result?.files) ? result.files.filter((file) => typeof file === 'string') : [],
+ notes: Array.isArray(result?.notes) ? result.notes.filter((note) => typeof note === 'string') : [],
+ };
+}
+
+function manualApplyResultShapeHint(eventId = 'EVENT_ID') {
+ return `Use live-poll.mjs --reply ${eventId} done --data '{"status":"done","appliedEntryIds":["ENTRY_ID"],"failed":[],"files":["src/page.html"],"notes":[]}'`;
+}
+
+function invalidManualApplyResult(reason, eventId, extra = {}) {
+ return {
+ ok: false,
+ body: {
+ error: 'invalid_manual_apply_result',
+ reason,
+ hint: manualApplyResultShapeHint(eventId),
+ ...extra,
+ },
+ };
+}
+
+function validateManualApplyResultMessage(msg, deferred) {
+ let data = msg?.data;
+ const eventId = msg?.id || deferred?.event?.id || 'EVENT_ID';
+ if (!data || typeof data !== 'object' || Array.isArray(data)) {
+ return invalidManualApplyResult('missing_result_data', eventId);
+ }
+ if ('entries' in data || 'ops' in data) {
+ return invalidManualApplyResult('summary_result_not_allowed', eventId);
+ }
+ if (!['done', 'partial', 'error'].includes(data.status)) {
+ return invalidManualApplyResult('invalid_status', eventId, { status: data.status ?? null });
+ }
+
+ for (const key of ['appliedEntryIds', 'failed', 'files', 'notes']) {
+ if (!Array.isArray(data[key])) {
+ return invalidManualApplyResult(`${key}_must_be_array`, eventId);
+ }
+ }
+
+ for (const [index, value] of data.appliedEntryIds.entries()) {
+ if (typeof value !== 'string' || !value) {
+ return invalidManualApplyResult('appliedEntryIds_must_contain_strings', eventId, { index });
+ }
+ }
+ for (const [index, value] of data.files.entries()) {
+ if (typeof value !== 'string' || !value) {
+ return invalidManualApplyResult('files_must_contain_strings', eventId, { index });
+ }
+ }
+ for (const [index, value] of data.notes.entries()) {
+ if (typeof value !== 'string') {
+ return invalidManualApplyResult('notes_must_contain_strings', eventId, { index });
+ }
+ }
+ for (const [index, item] of data.failed.entries()) {
+ if (!item || typeof item !== 'object' || Array.isArray(item)) {
+ return invalidManualApplyResult('failed_must_contain_objects', eventId, { index });
+ }
+ if (typeof item.entryId !== 'string' || !item.entryId) {
+ return invalidManualApplyResult('failed_entryId_required', eventId, { index });
+ }
+ if (typeof item.reason !== 'string' || !item.reason) {
+ return invalidManualApplyResult('failed_reason_required', eventId, { index });
+ }
+ }
+
+ const eventEntryIds = new Set((deferred?.batch?.entries || []).map((entry) => entry.id).filter(Boolean));
+ for (const entryId of data.appliedEntryIds) {
+ if (eventEntryIds.size > 0 && !eventEntryIds.has(entryId)) {
+ return invalidManualApplyResult('applied_entry_id_not_in_event', eventId, { entryId });
+ }
+ }
+ for (const item of data.failed) {
+ if (eventEntryIds.size > 0 && !eventEntryIds.has(item.entryId)) {
+ return invalidManualApplyResult('failed_entry_id_not_in_event', eventId, { entryId: item.entryId });
+ }
+ }
+
+ if (data.status === 'done') {
+ if (data.failed.length > 0) {
+ return invalidManualApplyResult('done_result_has_failed_entries', eventId);
+ }
+ if (countManualApplyOps(deferred?.batch) > 0 && data.appliedEntryIds.length === 0) {
+ return invalidManualApplyResult('done_result_missing_applied_entry_ids', eventId);
+ }
+ }
+ if (data.status === 'partial' && data.appliedEntryIds.length === 0 && data.failed.length === 0) {
+ return invalidManualApplyResult('partial_result_has_no_entries', eventId);
+ }
+ if (data.status === 'error' && data.appliedEntryIds.length > 0) {
+ return invalidManualApplyResult('error_result_has_applied_entries', eventId);
+ }
+
+ return {
+ ok: true,
+ result: {
+ status: data.status,
+ message: typeof data.message === 'string' ? data.message : undefined,
+ appliedEntryIds: data.appliedEntryIds,
+ failed: data.failed,
+ files: data.files,
+ notes: data.notes,
+ },
+ };
+}
+
+function firstFailureReason(result) {
+ const first = Array.isArray(result?.failed) ? result.failed.find(Boolean) : null;
+ return first?.reason || first?.message || null;
+}
+
+function markChunkEntriesFailed(failedByEntry, chunk, reason) {
+ for (const entryId of chunk.entryIds) {
+ if (failedByEntry.has(entryId)) continue;
+ failedByEntry.set(entryId, { entryId, reason, candidates: [] });
+ }
+}
+
+function splitManualApplyBatch(batch, maxOps) {
+ const totalOpCount = countManualApplyOps(batch);
+ if (totalOpCount <= maxOps) {
+ return [{
+ batch,
+ meta: null,
+ entryIds: new Set((batch?.entries || []).map((entry) => entry.id).filter(Boolean)),
+ opCountsByEntry: new Map((batch?.entries || []).map((entry) => [entry.id, Array.isArray(entry.ops) ? entry.ops.length : 0])),
+ }];
+ }
+
+ const rawChunks = [];
+ let current = createManualApplyChunkBuilder();
+ for (const entry of batch?.entries || []) {
+ const ops = entry.ops || [];
+ if (ops.length <= maxOps) {
+ if (current.opCount > 0 && current.opCount + ops.length > maxOps) {
+ rawChunks.push(current);
+ current = createManualApplyChunkBuilder();
+ }
+ for (const op of ops) addOpToManualApplyChunk(current, entry, op);
+ continue;
+ }
+ if (current.opCount > 0) {
+ rawChunks.push(current);
+ current = createManualApplyChunkBuilder();
+ }
+ for (const op of ops) {
+ if (current.opCount >= maxOps) {
+ rawChunks.push(current);
+ current = createManualApplyChunkBuilder();
+ }
+ addOpToManualApplyChunk(current, entry, op);
+ }
+ }
+ if (current.opCount > 0) rawChunks.push(current);
+
+ return rawChunks.map((chunk, index) => ({
+ batch: {
+ ...batch,
+ count: chunk.opCount,
+ entries: chunk.entries,
+ ops: chunk.ops,
+ candidates: filterManualApplyChunkCandidates(batch, chunk.refsByEntry),
+ context: {
+ ...(batch?.context || {}),
+ totalEntries: chunk.entries.length,
+ totalOps: chunk.opCount,
+ chunkIndex: index + 1,
+ chunkTotal: rawChunks.length,
+ totalApplyOps: totalOpCount,
+ },
+ },
+ meta: {
+ index: index + 1,
+ total: rawChunks.length,
+ opCount: chunk.opCount,
+ totalOpCount,
+ },
+ entryIds: new Set(chunk.entries.map((entry) => entry.id).filter(Boolean)),
+ opCountsByEntry: chunk.opCountsByEntry,
+ }));
+}
+
+function createManualApplyChunkBuilder() {
+ return {
+ entries: [],
+ entryById: new Map(),
+ entryIds: new Set(),
+ ops: [],
+ refsByEntry: new Map(),
+ opCountsByEntry: new Map(),
+ opCount: 0,
+ };
+}
+
+function addOpToManualApplyChunk(chunk, entry, op) {
+ let chunkEntry = chunk.entryById.get(entry.id);
+ if (!chunkEntry) {
+ chunkEntry = { ...entry, ops: [] };
+ chunk.entryById.set(entry.id, chunkEntry);
+ chunk.entryIds.add(entry.id);
+ chunk.entries.push(chunkEntry);
+ }
+ chunkEntry.ops.push(op);
+ chunk.ops.push({ ...op, entryId: op.entryId || entry.id });
+ if (!chunk.refsByEntry.has(entry.id)) chunk.refsByEntry.set(entry.id, new Set());
+ if (op.ref) chunk.refsByEntry.get(entry.id).add(op.ref);
+ chunk.opCountsByEntry.set(entry.id, (chunk.opCountsByEntry.get(entry.id) || 0) + 1);
+ chunk.opCount += 1;
+}
+
+function filterManualApplyChunkCandidates(batch, refsByEntry) {
+ return (batch?.candidates || []).filter((candidate) => {
+ const refs = refsByEntry.get(candidate.entryId);
+ if (!refs) return false;
+ if (!candidate.ref) return true;
+ return refs.has(candidate.ref);
+ });
+}
+
+function resolveApplyDeferred(eventId, body) {
+ const deferred = state.pendingApplyDeferreds.get(eventId);
+ if (!deferred) return false;
+ state.pendingApplyDeferreds.delete(eventId);
+ clearTimeout(deferred.timer);
+ removeManualApplyEvidence(deferred.event?.evidencePath);
+ deferred.resolve(body);
+ return true;
+}
+
+function rejectApplyDeferred(eventId, reason) {
+ const deferred = state.pendingApplyDeferreds.get(eventId);
+ if (!deferred) return false;
+ state.pendingApplyDeferreds.delete(eventId);
+ clearTimeout(deferred.timer);
+ removeManualApplyEvidence(deferred.event?.evidencePath);
+ deferred.reject(new Error(reason || 'chat_agent_error'));
+ return true;
+}
+
+function snapshotApplyEventFiles(batch) {
+ const snapshot = new Map();
+ for (const relativeFile of collectManualApplyFiles(batch)) {
+ const absolute = path.resolve(process.cwd(), relativeFile);
+ try {
+ snapshot.set(relativeFile, {
+ exists: fs.existsSync(absolute),
+ content: fs.existsSync(absolute) ? fs.readFileSync(absolute, 'utf-8') : '',
+ });
+ } catch {
+ // If a file cannot be read before dispatch, do not attempt late rollback.
+ }
+ }
+ return snapshot;
+}
+
+function manualApplyTransactionPath(cwd = process.cwd()) {
+ return path.join(getLiveDir(cwd), 'manual-edit-apply-transaction.json');
+}
+
+function readManualApplyTransaction(cwd = process.cwd()) {
+ const file = manualApplyTransactionPath(cwd);
+ if (!fs.existsSync(file)) return null;
+ try {
+ return JSON.parse(fs.readFileSync(file, 'utf-8'));
+ } catch {
+ return null;
+ }
+}
+
+function writeManualApplyTransaction({ cwd = process.cwd(), pageUrl = null, batch }) {
+ const file = manualApplyTransactionPath(cwd);
+ const files = collectManualApplyFiles(batch);
+ const transaction = {
+ version: 1,
+ id: randomUUID().replace(/-/g, '').slice(0, 8),
+ createdAt: new Date().toISOString(),
+ pageUrl,
+ entryIds: (batch?.entries || []).map((entry) => entry.id).filter(Boolean),
+ files: files.map((relativeFile) => {
+ const absolute = path.resolve(cwd, relativeFile);
+ const exists = fs.existsSync(absolute);
+ return {
+ file: relativeFile,
+ exists,
+ content: exists ? fs.readFileSync(absolute, 'utf-8') : '',
+ };
+ }),
+ };
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ fs.writeFileSync(`${file}.tmp`, JSON.stringify(transaction, null, 2) + '\n', 'utf-8');
+ fs.renameSync(`${file}.tmp`, file);
+ return transaction;
+}
+
+function clearManualApplyTransaction(cwd = process.cwd(), transactionId = null) {
+ const file = manualApplyTransactionPath(cwd);
+ if (!fs.existsSync(file)) return false;
+ if (transactionId) {
+ const existing = readManualApplyTransaction(cwd);
+ if (existing?.id && existing.id !== transactionId) return false;
+ }
+ try {
+ fs.unlinkSync(file);
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+function rollbackManualApplyTransaction({ cwd = process.cwd(), pageUrl = null, reason = 'manual_edit_transaction_rollback' } = {}) {
+ const transaction = readManualApplyTransaction(cwd);
+ if (!transaction) return null;
+ if (pageUrl && transaction.pageUrl && transaction.pageUrl !== pageUrl) return null;
+
+ let pendingIds = new Set();
+ try {
+ const buffer = readManualEditsBuffer(cwd);
+ pendingIds = new Set((buffer.entries || []).map((entry) => entry.id).filter(Boolean));
+ } catch {
+ pendingIds = new Set(transaction.entryIds || []);
+ }
+ const shouldRollback = (transaction.entryIds || []).some((id) => pendingIds.has(id));
+ if (!shouldRollback) {
+ clearManualApplyTransaction(cwd, transaction.id);
+ return { id: transaction.id, reason, rolledBackFiles: [], rollbackFailures: [], skipped: 'entries_not_pending' };
+ }
+
+ const rolledBackFiles = [];
+ const rollbackFailures = [];
+ for (const item of transaction.files || []) {
+ const relativeFile = normalizeProjectFile(item.file);
+ if (!relativeFile) continue;
+ const absolute = path.resolve(cwd, relativeFile);
+ try {
+ if (item.exists) {
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
+ fs.writeFileSync(absolute, item.content || '', 'utf-8');
+ } else if (fs.existsSync(absolute)) {
+ fs.rmSync(absolute);
+ }
+ rolledBackFiles.push(relativeFile);
+ } catch (err) {
+ rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
+ }
+ }
+ clearManualApplyTransaction(cwd, transaction.id);
+ recordManualEditActivity('manual_edit_transaction_rolled_back', {
+ id: transaction.id,
+ pageUrl: transaction.pageUrl || null,
+ reason,
+ entryIds: transaction.entryIds || [],
+ rolledBackFiles: rolledBackFiles.map(summarizeManualLogFile).filter(Boolean),
+ rollbackFailures: summarizeManualDiagnostics(rollbackFailures),
+ });
+ return { id: transaction.id, reason, rolledBackFiles, rollbackFailures };
+}
+
+function collectManualApplyFiles(batch, extraFiles = []) {
+ const files = [];
+ for (const entry of batch?.entries || []) {
+ for (const op of entry.ops || []) files.push(op.sourceHint?.file);
+ }
+ for (const candidate of batch?.candidates || []) {
+ files.push(candidate.sourceHint?.relativeFile, candidate.sourceHint?.file);
+ for (const item of candidate.textMatches || []) files.push(item.file);
+ for (const item of candidate.objectKeyMatches || []) files.push(item.file);
+ for (const item of candidate.locatorMatches || []) files.push(item.file);
+ for (const item of candidate.contextTextMatches || []) files.push(item.file);
+ }
+ files.push(...(extraFiles || []));
+ return [...new Set(files)]
+ .map((file) => normalizeProjectFile(file))
+ .filter(Boolean);
+}
+
+function normalizeProjectFile(file) {
+ if (!file || typeof file !== 'string') return null;
+ const absolute = path.isAbsolute(file) ? file : path.resolve(process.cwd(), file);
+ const relative = path.relative(process.cwd(), absolute);
+ if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) return null;
+ return relative;
+}
+
+function rollbackApplySnapshot(batch, rollbackSnapshot, extraFiles = [], reason = 'manual_edit_apply_snapshot_rollback') {
+ const scope = collectManualApplyFiles(batch, extraFiles);
+ const rolledBackFiles = [];
+ const rollbackFailures = [];
+ for (const relativeFile of scope) {
+ const before = rollbackSnapshot?.get(relativeFile);
+ if (!before) continue;
+ const absolute = path.resolve(process.cwd(), relativeFile);
+ try {
+ if (before.exists) {
+ fs.mkdirSync(path.dirname(absolute), { recursive: true });
+ fs.writeFileSync(absolute, before.content, 'utf-8');
+ } else if (fs.existsSync(absolute)) {
+ fs.rmSync(absolute);
+ }
+ rolledBackFiles.push(relativeFile);
+ } catch (err) {
+ rollbackFailures.push({ file: relativeFile, reason: 'restore_failed', message: err.message || String(err) });
+ }
+ }
+ return { rolledBackFiles, rollbackFailures };
+}
+
+function rollbackTimedOutApplyReply(msg) {
+ const details = state.timedOutApplyIds.get(msg.id);
+ if (!details) return { rolledBackFiles: [], rollbackFailures: [] };
+ state.timedOutApplyIds.delete(msg.id);
+ return rollbackApplySnapshot(details.batch, details.rollbackSnapshot, msg.data?.files || [], 'stale_manual_edit_apply_reply');
+}
+
+// Cap per-annotation upload size. A full 1920×1080 PNG is typically <1 MB;
+// cap at 10 MB to guard against runaway writes from a misbehaving client.
+const MAX_ANNOTATION_BYTES = 10 * 1024 * 1024;
+
+function enqueueEvent(event) {
+ if (!event || (event.id && state.pendingEvents.some((entry) => entry.event?.id === event.id && entry.event?.type === event.type))) return;
+ state.pendingEvents.push({ event, leaseUntil: 0, seq: state.nextEventSeq++ });
+ flushPendingPolls();
+}
+
+function restorePendingEventsFromStore() {
+ if (!state.sessionStore) return;
+ for (const snapshot of state.sessionStore.listActiveSessions()) {
+ if (snapshot.pendingEvent) enqueueEvent(snapshot.pendingEvent);
+ }
+}
+
+function findAvailablePendingEvent(now = Date.now()) {
+ for (const entry of state.pendingEvents) {
+ if (entry.leaseUntil && entry.leaseUntil > now) continue;
+ return entry;
+ }
+ return null;
+}
+
+function leaseEvent(entry, leaseMs) {
+ if (!entry.event?.id) {
+ const idx = state.pendingEvents.indexOf(entry);
+ if (idx !== -1) state.pendingEvents.splice(idx, 1);
+ return entry.event;
+ }
+ entry.leaseUntil = Date.now() + leaseMs;
+ return entry.event;
+}
+
+function acknowledgePendingEvent(id) {
+ if (!id) return false;
+ const idx = state.pendingEvents.findIndex((entry) => entry.event?.id === id);
+ if (idx === -1) return false;
+ const acknowledged = state.pendingEvents[idx].event;
+ state.pendingEvents.splice(idx, 1);
+ scheduleLeaseFlush();
+ return acknowledged;
+}
+
+function manualApplyReplyCommand(eventOrId = 'EVENT_ID') {
+ const id = typeof eventOrId === 'string' ? eventOrId : eventOrId?.id || 'EVENT_ID';
+ return `live-poll.mjs --reply ${id} done --data ''`;
+}
+
+function buildManualApplyAgentAction(eventOrId = 'EVENT_ID') {
+ return {
+ kind: 'manual_edit_apply',
+ required: 'apply_source_edits_then_reply',
+ replyCommand: manualApplyReplyCommand(eventOrId),
+ warning: 'Polling only leases this work item; it does not commit source edits.',
+ };
+}
+
+function summarizeManualApplyEvent(event = {}, batch = event.batch) {
+ const entries = Array.isArray(batch?.entries) ? batch.entries : [];
+ const opCount = entries.reduce((sum, entry) => sum + (Array.isArray(entry.ops) ? entry.ops.length : 0), 0);
+ return {
+ pageUrl: event.pageUrl || null,
+ chunk: event.chunk || null,
+ entryCount: entries.length,
+ opCount,
+ files: collectManualApplyFiles(batch),
+ };
+}
+
+function summarizePendingEventForStatus(entry) {
+ const event = entry.event || {};
+ const summary = {
+ id: event.id,
+ type: event.type,
+ leased: !!(entry.leaseUntil && entry.leaseUntil > Date.now()),
+ leaseUntil: entry.leaseUntil || null,
+ };
+ if (event.type === 'manual_edit_apply') {
+ summary.pageUrl = event.pageUrl || null;
+ summary.chunk = event.chunk || null;
+ summary.repair = event.repair || null;
+ summary.evidencePath = event.evidencePath || null;
+ summary.agentAction = event.agentAction || buildManualApplyAgentAction(event);
+ summary.manualApplySummary = summarizeManualApplyEvent(event, state.pendingApplyDeferreds.get(event.id)?.batch || event.batch);
+ }
+ return summary;
+}
+
+function cancelPendingManualApplyEvents(pageUrl, reason = 'manual_edit_discarded') {
+ const canceledById = new Map();
+ const shouldCancel = (event) => event?.type === 'manual_edit_apply' && (!pageUrl || event.pageUrl === pageUrl);
+
+ for (let i = state.pendingEvents.length - 1; i >= 0; i -= 1) {
+ const event = state.pendingEvents[i]?.event;
+ if (!shouldCancel(event)) continue;
+ state.pendingEvents.splice(i, 1);
+ removeManualApplyEvidence(event.evidencePath);
+ canceledById.set(event.id, {
+ id: event.id,
+ pageUrl: event.pageUrl,
+ entryCount: event.batch?.entries?.length || 0,
+ });
+ }
+
+ for (const [eventId, deferred] of [...state.pendingApplyDeferreds.entries()]) {
+ if (!shouldCancel(deferred.event)) continue;
+ state.pendingApplyDeferreds.delete(eventId);
+ clearTimeout(deferred.timer);
+ const rollback = rollbackApplySnapshot(deferred.batch, deferred.rollbackSnapshot, [], reason);
+ tombstoneTimedOutApplyId(eventId, {
+ batch: deferred.batch,
+ rollbackSnapshot: deferred.rollbackSnapshot,
+ reason,
+ });
+ removeManualApplyEvidence(deferred.event?.evidencePath);
+ canceledById.set(eventId, {
+ id: eventId,
+ pageUrl: deferred.pageUrl,
+ entryCount: deferred.batch?.entries?.length || 0,
+ rolledBackFiles: rollback.rolledBackFiles,
+ rollbackFailures: rollback.rollbackFailures,
+ });
+ deferred.reject(new Error(reason));
+ }
+
+ if (canceledById.size > 0) flushPendingPolls();
+ return [...canceledById.values()];
+}
+
+function scheduleLeaseFlush() {
+ if (state.leaseTimer) {
+ clearTimeout(state.leaseTimer);
+ state.leaseTimer = null;
+ }
+ if (state.pendingPolls.length === 0) return;
+ const now = Date.now();
+ const nextLeaseUntil = state.pendingEvents
+ .map((entry) => entry.leaseUntil || 0)
+ .filter((leaseUntil) => leaseUntil > now)
+ .sort((a, b) => a - b)[0];
+ if (!nextLeaseUntil) return;
+ state.leaseTimer = setTimeout(() => {
+ state.leaseTimer = null;
+ flushPendingPolls();
+ }, Math.max(0, nextLeaseUntil - now));
+}
+
+function flushPendingPolls() {
+ let changed = false;
+ while (state.pendingPolls.length > 0) {
+ const entry = findAvailablePendingEvent();
+ if (!entry) {
+ scheduleLeaseFlush();
+ broadcastAgentPollingIfChanged();
+ return;
+ }
+ const poll = state.pendingPolls.shift();
+ poll.resolve(leaseEvent(entry, poll.leaseMs));
+ changed = true;
+ }
+ scheduleLeaseFlush();
+ if (changed) broadcastAgentPollingIfChanged();
+}
+
+function agentPollingConnected() {
+ return state.pendingPolls.length > 0;
+}
+
+function broadcastAgentPollingIfChanged() {
+ const connected = agentPollingConnected();
+ if (state.lastAgentPollingBroadcast === connected) return;
+ state.lastAgentPollingBroadcast = connected;
+ broadcast({ type: 'agent_polling', connected });
+}
+
+/** Push a message to all connected SSE clients. */
+function broadcast(msg) {
+ const data = 'data: ' + JSON.stringify(msg) + '\n\n';
+ for (const res of state.sseClients) {
+ try { res.write(data); } catch { /* client gone */ }
+ }
+}
+
+function recordManualEditActivity(type, details = {}) {
+ const entry = {
+ seq: state.nextManualEditSeq++,
+ type,
+ ts: new Date().toISOString(),
+ ...details,
+ };
+ state.manualEditActivity = entry;
+ if (DEBUG_MANUAL_EDIT_EVENTS) {
+ try {
+ const filePath = path.join(getLiveDir(process.cwd()), 'manual-edit-events.jsonl');
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
+ fs.appendFileSync(filePath, JSON.stringify(entry) + '\n');
+ } catch {
+ /* diagnostics are best-effort; never block live mode on observability */
+ }
+ }
+ broadcast(entry);
+ return entry;
+}
+
+function getManualEditStatus() {
+ try {
+ const { totalCount, perPage } = countPendingByPage(process.cwd());
+ return { totalCount, perPage, lastActivity: state.manualEditActivity };
+ } catch (err) {
+ return {
+ totalCount: null,
+ perPage: {},
+ lastActivity: state.manualEditActivity,
+ error: err.message,
+ };
+ }
+}
+
+function summarizePendingManualEditBatch(pageUrl = null) {
+ try {
+ const buffer = readManualEditsBuffer(process.cwd());
+ const entries = (buffer.entries || [])
+ .filter((entry) => !pageUrl || entry.pageUrl === pageUrl);
+ return {
+ pendingEntryCount: entries.length,
+ pendingOpCount: entries.reduce((sum, entry) => sum + (entry.ops?.length || 0), 0),
+ };
+ } catch (err) {
+ return { pendingSummaryError: err.message || String(err) };
+ }
+}
+
+function summarizeManualApplyFailures(failed) {
+ if (!Array.isArray(failed)) return [];
+ return failed.slice(0, 20).map((item) => ({
+ id: item.id || item.entryId || null,
+ reason: item.reason || item.message || 'failed',
+ message: compactManualLogText(item.message, 300),
+ files: Array.isArray(item.files) ? item.files.slice(0, 12).map(summarizeManualLogFile).filter(Boolean) : undefined,
+ checks: summarizeManualDiagnostics(item.checks),
+ failures: summarizeManualDiagnostics(item.failures),
+ candidates: summarizeManualDiagnostics(item.candidates),
+ }));
+}
+
+function summarizeManualDiagnostics(items) {
+ if (!Array.isArray(items) || items.length === 0) return undefined;
+ return items.slice(0, 12).map((item) => ({
+ reason: item.reason || item.kind || undefined,
+ detail: compactManualLogText(item.detail, 220),
+ message: compactManualLogText(item.message, 300),
+ file: summarizeManualLogFile(item.file || item.relativeFile),
+ line: item.line || undefined,
+ ref: compactManualLogText(item.ref, 180),
+ marker: compactManualLogText(item.marker, 120),
+ files: Array.isArray(item.files) ? item.files.slice(0, 8).map(summarizeManualLogFile).filter(Boolean) : undefined,
+ }));
+}
+
+function summarizeManualLogFile(file) {
+ if (!file || typeof file !== 'string') return undefined;
+ if (!path.isAbsolute(file)) return file;
+ const relative = path.relative(process.cwd(), file);
+ return relative && !relative.startsWith('..') && !path.isAbsolute(relative) ? relative : file;
+}
+
+function compactManualLogText(value, max = 200) {
+ if (typeof value !== 'string') return undefined;
+ const normalized = value.replace(/\s+/g, ' ').trim();
+ if (normalized.length <= max) return normalized;
+ return normalized.slice(0, max) + `... [truncated ${normalized.length - max} chars]`;
+}
+
+// ---------------------------------------------------------------------------
+// Load scripts
+// ---------------------------------------------------------------------------
+
+function loadBrowserScripts() {
+ // Detection script: prefer the skill-bundled detector, then fall back to
+ // source/npm package locations for local development and older installs.
+ // This one IS cached — detect.js rarely changes during a session.
+ const detectPaths = [
+ path.join(__dirname, 'detector', 'detect-antipatterns-browser.js'),
+ path.join(__dirname, '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
+ path.join(__dirname, '..', '..', '..', '..', 'cli', 'engine', 'detect-antipatterns-browser.js'),
+ path.join(process.cwd(), 'node_modules', 'impeccable', 'cli', 'engine', 'detect-antipatterns-browser.js'),
+ ];
+ let detectScript = '';
+ for (const p of detectPaths) {
+ try { detectScript = fs.readFileSync(p, 'utf-8'); break; } catch { /* try next */ }
+ }
+
+ // live-browser.js: DO NOT cache. Return the path so the /live.js handler
+ // can re-read on every request. Editing the browser script during iteration
+ // should land on the next tab reload, not require a server restart.
+ const sessionPath = path.join(__dirname, 'live-browser-session.js');
+ const livePath = path.join(__dirname, 'live-browser.js');
+ for (const p of [sessionPath, livePath]) {
+ if (!fs.existsSync(p)) {
+ process.stderr.write('Error: live browser script not found at ' + p + '\n');
+ process.exit(1);
+ }
+ }
+
+ return { detectScript, sessionPath, livePath };
+}
+
+function hasProjectContext() {
+ // PRODUCT.md carries brand voice / anti-references — that's what determines
+ // whether variants are brand-aware. DESIGN.md (visual tokens) is a separate
+ // concern, surfaced by the design panel's own empty state.
+ try {
+ fs.accessSync(path.join(CONTEXT_DIR, 'PRODUCT.md'), fs.constants.R_OK);
+ return true;
+ } catch { return false; }
+}
+
+function statOrNull(filePath) {
+ try { return fs.statSync(filePath); } catch { return null; }
+}
+
+// HTTP request handler
+// ---------------------------------------------------------------------------
+
+function createRequestHandler({ detectScript, sessionPath, livePath }) {
+ return (req, res) => {
+ const url = new URL(req.url, `http://localhost:${state.port}`);
+ res.setHeader('Access-Control-Allow-Origin', '*');
+ res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
+ if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; }
+
+ const p = url.pathname;
+
+ // --- Scripts ---
+ if (p === '/live.js') {
+ // Re-read from disk each request so edits to live-browser.js land on
+ // the next tab reload. No-store headers prevent browser caching across
+ // sessions — during iteration, a cached old script silently breaks
+ // every subsequent session.
+ let sessionScript;
+ let liveScript;
+ try {
+ sessionScript = fs.readFileSync(sessionPath, 'utf-8');
+ liveScript = fs.readFileSync(livePath, 'utf-8');
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'text/plain' });
+ res.end('Error reading live browser scripts: ' + err.message);
+ return;
+ }
+ const body =
+ `window.__IMPECCABLE_TOKEN__ = '${state.token}';\n` +
+ `window.__IMPECCABLE_PORT__ = ${state.port};\n` +
+ sessionScript + '\n' +
+ liveScript;
+ res.writeHead(200, {
+ 'Content-Type': 'application/javascript',
+ 'Cache-Control': 'no-store, no-cache, must-revalidate, max-age=0',
+ 'Pragma': 'no-cache',
+ });
+ res.end(body);
+ return;
+ }
+ if (p === '/detect.js' || p === '/') {
+ if (!detectScript) { res.writeHead(404); res.end('Not available'); return; }
+ res.writeHead(200, { 'Content-Type': 'application/javascript' });
+ res.end(detectScript);
+ return;
+ }
+
+ // --- Vendored modern-screenshot (UMD build) ---
+ // Lazy-loaded by live.js when the user clicks Go; exposes
+ // window.modernScreenshot.domToBlob(...) for capture.
+ if (p === '/modern-screenshot.js') {
+ const vendorPath = path.join(__dirname, 'modern-screenshot.umd.js');
+ try {
+ res.writeHead(200, {
+ 'Content-Type': 'application/javascript',
+ 'Cache-Control': 'public, max-age=31536000, immutable',
+ });
+ res.end(fs.readFileSync(vendorPath));
+ } catch {
+ res.writeHead(404); res.end('Vendor script not found');
+ }
+ return;
+ }
+
+ // --- Annotation upload (browser → server, raw PNG body) ---
+ // Client generates the eventId, POSTs the PNG, then POSTs the generate
+ // event with screenshotPath already set. Keeps bytes out of the SSE/poll
+ // bridge and preserves the "one shot from the user's POV" UX.
+ if (p === '/annotation' && req.method === 'POST') {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
+ const eventId = url.searchParams.get('eventId');
+ if (!eventId || !/^[A-Za-z0-9_-]{1,64}$/.test(eventId)) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid eventId' }));
+ return;
+ }
+ if ((req.headers['content-type'] || '').toLowerCase() !== 'image/png') {
+ res.writeHead(415, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Content-Type must be image/png' }));
+ return;
+ }
+ if (!state.sessionDir) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Session dir unavailable' }));
+ return;
+ }
+ const chunks = [];
+ let total = 0;
+ let aborted = false;
+ req.on('data', (c) => {
+ if (aborted) return;
+ total += c.length;
+ if (total > MAX_ANNOTATION_BYTES) {
+ aborted = true;
+ res.writeHead(413, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Payload too large' }));
+ req.destroy();
+ return;
+ }
+ chunks.push(c);
+ });
+ req.on('end', () => {
+ if (aborted) return;
+ const absPath = path.join(state.sessionDir, eventId + '.png');
+ try {
+ fs.writeFileSync(absPath, Buffer.concat(chunks));
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Write failed: ' + err.message }));
+ return;
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, path: absPath }));
+ });
+ req.on('error', () => {
+ if (!aborted) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Upload failed' }));
+ }
+ });
+ return;
+ }
+
+ // --- Health ---
+ if (p === '/status') {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ error: 'Unauthorized' })); return; }
+ const sessions = state.sessionStore ? state.sessionStore.listActiveSessions() : [];
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ status: 'ok',
+ port: state.port,
+ connectedClients: state.sseClients.size,
+ pendingEvents: state.pendingEvents.map((entry) => summarizePendingEventForStatus(entry)),
+ agentPolling: agentPollingConnected(),
+ activeSessions: sessions,
+ manualEdits: getManualEditStatus(),
+ }));
+ return;
+ }
+
+ if (p === '/health') {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ status: 'ok', port: state.port, mode: 'variant',
+ hasProjectContext: hasProjectContext(),
+ connectedClients: state.sseClients.size,
+ }));
+ return;
+ }
+
+ // --- Design system (unified v2 response) + raw ---
+ // /design-system.json returns both parsed DESIGN.md and .impeccable/design.json
+ // sidecar when present. Panel merges them:
+ // { present, parsed, sidecar, hasMd, hasSidecar,
+ // mdNewerThanJson, parseError?, sidecarError? }
+ // - parsed: output of parseDesignMd (frontmatter
+ // + six canonical sections) when DESIGN.md exists.
+ // - sidecar: .impeccable/design.json contents when present.
+ // Expected shape: schemaVersion 2, carrying
+ // extensions + components + narrative.
+ // /design-system/raw returns DESIGN.md markdown verbatim
+ if (p === '/design-system.json' || p === '/design-system/raw') {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
+
+ const mdPath = path.join(CONTEXT_DIR, 'DESIGN.md');
+ const jsonPath = resolveDesignSidecarPath(process.cwd(), CONTEXT_DIR) || getDesignSidecarPath(process.cwd());
+ const mdStat = statOrNull(mdPath);
+ const jsonStat = statOrNull(jsonPath);
+
+ if (p === '/design-system/raw') {
+ if (!mdStat) { res.writeHead(404); res.end('Not found'); return; }
+ res.writeHead(200, { 'Content-Type': 'text/markdown; charset=utf-8' });
+ res.end(fs.readFileSync(mdPath, 'utf-8'));
+ return;
+ }
+
+ if (!mdStat && !jsonStat) {
+ res.writeHead(404, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ present: false }));
+ return;
+ }
+
+ const response = {
+ present: true,
+ hasMd: !!mdStat,
+ hasSidecar: !!jsonStat,
+ mdNewerThanJson: !!(mdStat && jsonStat && mdStat.mtimeMs > jsonStat.mtimeMs + 1000),
+ };
+
+ if (mdStat) {
+ try {
+ response.parsed = parseDesignMd(fs.readFileSync(mdPath, 'utf-8'));
+ } catch (err) {
+ response.parseError = err.message;
+ }
+ }
+
+ if (jsonStat) {
+ try {
+ response.sidecar = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'));
+ } catch (err) {
+ response.sidecarError = 'Failed to parse .impeccable/design.json: ' + err.message;
+ }
+ }
+
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(response));
+ return;
+ }
+
+ // --- Source file (no-HMR fallback) ---
+ if (p === '/source') {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
+ const filePath = url.searchParams.get('path');
+ if (!filePath || filePath.includes('..')) { res.writeHead(400); res.end('Bad path'); return; }
+ const absPath = path.resolve(process.cwd(), filePath);
+ if (!absPath.startsWith(process.cwd())) { res.writeHead(403); res.end('Forbidden'); return; }
+ let content;
+ try { content = fs.readFileSync(absPath, 'utf-8'); }
+ catch { res.writeHead(404); res.end('File not found'); return; }
+ res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+ res.end(content);
+ return;
+ }
+
+ // --- SSE: server→browser push (replaces WebSocket) ---
+ if (p === '/events' && req.method === 'GET') {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
+ res.writeHead(200, {
+ 'Content-Type': 'text/event-stream',
+ 'Cache-Control': 'no-cache',
+ 'Connection': 'keep-alive',
+ });
+ res.write('data: ' + JSON.stringify({
+ type: 'connected',
+ hasProjectContext: hasProjectContext(),
+ agentPolling: agentPollingConnected(),
+ }) + '\n\n');
+
+ state.sseClients.add(res);
+ clearTimeout(state.exitTimer);
+
+ // Keepalive: SSE comment every 30s prevents silent connection drops.
+ const heartbeat = setInterval(() => {
+ try { res.write(': keepalive\n\n'); } catch { clearInterval(heartbeat); }
+ }, SSE_HEARTBEAT_INTERVAL);
+
+ req.on('close', () => {
+ clearInterval(heartbeat);
+ state.sseClients.delete(res);
+ if (state.sseClients.size === 0) {
+ clearTimeout(state.exitTimer);
+ state.exitTimer = setTimeout(() => {
+ if (state.sseClients.size === 0) enqueueEvent({ type: 'exit' });
+ }, 8000);
+ }
+ });
+ return;
+ }
+
+ // --- Manual copy edits: Save stages entries, Apply commits the staged
+ // page batch through the local AI copy-edit runner.
+ if (p === '/manual-edit-stash' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ let msg;
+ try { msg = JSON.parse(body); } catch {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
+ return;
+ }
+ if (msg.token !== state.token) {
+ res.writeHead(401, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
+ return;
+ }
+ const error = validateEvent({ ...msg, type: 'manual_edits' });
+ if (error) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error }));
+ return;
+ }
+ try {
+ stageManualEditEntry(process.cwd(), {
+ id: msg.id,
+ pageUrl: msg.pageUrl,
+ element: msg.element,
+ ops: msg.ops,
+ });
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'stash_write_failed', message: err.message }));
+ return;
+ }
+ const { totalCount, perPage } = countPendingByPage(process.cwd());
+ const pendingCount = perPage[msg.pageUrl] || 0;
+ recordManualEditActivity('manual_edit_stashed', {
+ id: msg.id,
+ pageUrl: msg.pageUrl,
+ opCount: msg.ops.length,
+ pendingCount,
+ totalCount,
+ hintedFileCount: new Set((msg.ops || []).map((op) => summarizeManualLogFile(op.sourceHint?.file)).filter(Boolean)).size,
+ });
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true, pendingCount, totalCount, perPage }));
+ });
+ return;
+ }
+
+ // GET /manual-edit-stash?pageUrl= → { count, totalCount, perPage, entries }
+ if (p === '/manual-edit-stash' && req.method === 'GET') {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
+ const pageUrl = url.searchParams.get('pageUrl') || '';
+ const { totalCount, perPage } = countPendingByPage(process.cwd());
+ const buffer = readManualEditsBuffer(process.cwd());
+ const entriesForPage = pageUrl ? buffer.entries.filter((e) => e.pageUrl === pageUrl) : buffer.entries;
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ count: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
+ totalCount,
+ perPage,
+ entries: entriesForPage,
+ }));
+ return;
+ }
+
+ // POST /manual-edit-commit?pageUrl= → ask the AI to apply the staged page batch.
+ if (p === '/manual-edit-commit' && req.method === 'POST') {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
+ const pageUrl = url.searchParams.get('pageUrl');
+ const asyncMode = /^(1|true|yes)$/i.test(url.searchParams.get('async') || '');
+ const repairOnly = /^(1|true|yes)$/i.test(url.searchParams.get('repair') || '');
+ const existingTransaction = readManualApplyTransaction(process.cwd());
+ if (repairOnly && !existingTransaction) {
+ res.writeHead(409, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'manual_edit_repair_transaction_missing' }));
+ return;
+ }
+ const recoveredTransaction = repairOnly ? null : rollbackManualApplyTransaction({
+ cwd: process.cwd(),
+ pageUrl,
+ reason: 'manual_edit_commit_recovered_abandoned_transaction',
+ });
+ const before = getManualEditStatus();
+ const pendingCount = pageUrl ? (before.perPage[pageUrl] || 0) : before.totalCount;
+ recordManualEditActivity('manual_edit_commit_started', {
+ pageUrl,
+ repairOnly,
+ pendingCount,
+ totalCount: before.totalCount,
+ recoveredTransaction: recoveredTransaction ? {
+ id: recoveredTransaction.id,
+ reason: recoveredTransaction.reason,
+ skipped: recoveredTransaction.skipped,
+ rolledBackFiles: recoveredTransaction.rolledBackFiles,
+ rollbackFailures: summarizeManualDiagnostics(recoveredTransaction.rollbackFailures),
+ } : null,
+ ...summarizePendingManualEditBatch(pageUrl),
+ });
+ if (asyncMode) {
+ res.writeHead(202, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ status: 'started',
+ pendingCount,
+ totalCount: before.totalCount,
+ perPage: before.perPage,
+ }));
+ }
+ (async () => {
+ let result;
+ let routedProvider = 'subprocess';
+ let transaction = null;
+ let commitBatch = null;
+ try {
+ if (pendingCount > 0) {
+ const transactionBatch = buildManualEditEvidence({ cwd: process.cwd(), pageUrl });
+ commitBatch = transactionBatch;
+ if (!repairOnly && countManualApplyOps(transactionBatch) > 0) {
+ transaction = writeManualApplyTransaction({
+ cwd: process.cwd(),
+ pageUrl,
+ batch: transactionBatch,
+ });
+ } else if (repairOnly && existingTransaction) {
+ transaction = existingTransaction;
+ }
+ }
+ const requestedMode = (process.env.IMPECCABLE_LIVE_COPY_AGENT || 'auto').trim().toLowerCase();
+ const useChatRoute = requestedMode === 'chat'
+ || (requestedMode === 'auto' && chatAgentLikelyActive());
+ if (useChatRoute) {
+ routedProvider = 'chat';
+ const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
+ result = await commitManualEdits({
+ cwd: process.cwd(),
+ pageUrl,
+ provider: 'chat',
+ env: process.env,
+ timeoutMs,
+ chatAvailable: chatAgentLikelyActive,
+ applyBatchToSource: (batch, context) => pushApplyBatchInChunksAndWait(batch, pageUrl, context),
+ repairOnly,
+ transactionId: transaction?.id || existingTransaction?.id || null,
+ batch: commitBatch,
+ });
+ } else {
+ const timeoutMs = Number(process.env.IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS || 120000);
+ const provider = ['codex', 'claude', 'mock'].includes(requestedMode) ? requestedMode : undefined;
+ result = await commitManualEdits({
+ cwd: process.cwd(),
+ pageUrl,
+ provider,
+ env: process.env,
+ timeoutMs,
+ chatAvailable: chatAgentLikelyActive,
+ repairOnly,
+ transactionId: transaction?.id || existingTransaction?.id || null,
+ batch: commitBatch,
+ });
+ }
+ } catch (err) {
+ if (transaction) {
+ rollbackManualApplyTransaction({
+ cwd: process.cwd(),
+ pageUrl,
+ reason: 'manual_edit_commit_exception',
+ });
+ }
+ const message = err.stderr?.toString?.() || err.message;
+ recordManualEditActivity('manual_edit_commit_failed', {
+ pageUrl,
+ provider: routedProvider,
+ error: 'manual_edit_commit_failed',
+ message,
+ transactionId: transaction?.id || null,
+ });
+ if (!asyncMode) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ error: 'manual_edit_commit_failed',
+ message,
+ }));
+ }
+ return;
+ } finally {
+ if (transaction) {
+ const shouldKeepTransaction = result?.needsManualDecision === true;
+ if (!shouldKeepTransaction) clearManualApplyTransaction(process.cwd(), transaction.id);
+ }
+ }
+ const { totalCount, perPage } = countPendingByPage(process.cwd());
+ if (result?.needsManualDecision) {
+ recordManualEditActivity('manual_edit_repair_needs_decision', {
+ pageUrl,
+ provider: routedProvider,
+ transactionId: transaction?.id || existingTransaction?.id || null,
+ repair: result.repair || null,
+ failed: summarizeManualApplyFailures(result.failed),
+ files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [],
+ remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
+ totalCount,
+ });
+ } else {
+ recordManualEditActivity('manual_edit_commit_done', {
+ pageUrl,
+ provider: routedProvider,
+ reason: result.reason || null,
+ repair: result.repair || null,
+ appliedCount: Array.isArray(result.applied) ? result.applied.length : 0,
+ failedCount: Array.isArray(result.failed) ? result.failed.length : 0,
+ failed: summarizeManualApplyFailures(result.failed),
+ files: Array.isArray(result.files) ? result.files.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [],
+ warnings: summarizeManualDiagnostics(result.warnings),
+ rolledBackFiles: Array.isArray(result.rolledBackFiles) ? result.rolledBackFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : [],
+ rollbackFailures: summarizeManualDiagnostics(result.rollbackFailures),
+ unreportedFiles: Array.isArray(result.unreportedFiles) ? result.unreportedFiles.slice(0, 20).map(summarizeManualLogFile).filter(Boolean) : undefined,
+ noteCount: Array.isArray(result.notes) ? result.notes.length : 0,
+ cleared: result.cleared || 0,
+ remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
+ totalCount,
+ });
+ }
+ if (!asyncMode) {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ...result, totalCount, perPage }));
+ }
+ })();
+ return;
+ }
+
+ // POST /manual-edit-repair-decision → user resolves an exhausted repair loop.
+ if (p === '/manual-edit-repair-decision' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (chunk) => { body += chunk; });
+ req.on('end', () => {
+ let payload = {};
+ try { payload = body ? JSON.parse(body) : {}; } catch {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
+ return;
+ }
+ const token = payload.token || url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
+ const pageUrl = payload.pageUrl || url.searchParams.get('pageUrl') || null;
+ const action = String(payload.action || url.searchParams.get('action') || '').trim().toLowerCase();
+ if (action !== 'rollback') {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'unsupported_manual_edit_repair_decision', action }));
+ return;
+ }
+ const rollback = rollbackManualApplyTransaction({
+ cwd: process.cwd(),
+ pageUrl,
+ reason: 'manual_edit_user_requested_rollback',
+ });
+ const { totalCount, perPage } = countPendingByPage(process.cwd());
+ const response = {
+ action,
+ pageUrl,
+ rollback,
+ remainingCount: pageUrl ? (perPage[pageUrl] || 0) : totalCount,
+ totalCount,
+ perPage,
+ };
+ recordManualEditActivity('manual_edit_repair_rollback_done', response);
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(response));
+ });
+ return;
+ }
+
+ // POST /manual-edit-discard?pageUrl= → drops entries (all if no pageUrl)
+ if (p === '/manual-edit-discard' && req.method === 'POST') {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
+ const pageUrl = url.searchParams.get('pageUrl');
+ let discarded;
+ let discardedEntries = [];
+ let canceledApplyEvents = [];
+ let transactionRollback = null;
+ try {
+ const buffer = readManualEditsBuffer(process.cwd());
+ transactionRollback = rollbackManualApplyTransaction({
+ cwd: process.cwd(),
+ pageUrl,
+ reason: 'manual_edit_discarded',
+ });
+ if (pageUrl) {
+ discardedEntries = buffer.entries.filter((entry) => entry.pageUrl === pageUrl);
+ discarded = removeManualEditEntries(process.cwd(), (entry) => entry.pageUrl === pageUrl);
+ } else {
+ discardedEntries = buffer.entries;
+ discarded = truncateManualEditsBuffer(process.cwd());
+ }
+ canceledApplyEvents = cancelPendingManualApplyEvents(pageUrl);
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'discard_failed', message: err.message }));
+ return;
+ }
+ const { totalCount, perPage } = countPendingByPage(process.cwd());
+ recordManualEditActivity('manual_edit_discarded', {
+ pageUrl,
+ discarded,
+ canceledApplyIds: canceledApplyEvents.map((event) => event.id),
+ transactionRollback: transactionRollback ? {
+ id: transactionRollback.id,
+ rolledBackFiles: transactionRollback.rolledBackFiles?.map(summarizeManualLogFile).filter(Boolean) || [],
+ rollbackFailures: summarizeManualDiagnostics(transactionRollback.rollbackFailures),
+ skipped: transactionRollback.skipped,
+ } : undefined,
+ totalCount,
+ });
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ discarded, entries: discardedEntries, canceledApplyEvents, totalCount, perPage }));
+ return;
+ }
+
+ // Defense in depth: redirect any stragglers from the old /manual-edit endpoint.
+ if (p === '/manual-edit' && req.method === 'POST') {
+ res.writeHead(410, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: '/manual-edit is removed; use /manual-edit-stash and /manual-edit-commit for staged copy edits.' }));
+ return;
+ }
+
+ // --- Browser→server events (replaces WebSocket messages) ---
+ if (p === '/events' && req.method === 'POST') {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ let msg;
+ try { msg = JSON.parse(body); } catch {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
+ return;
+ }
+ if (msg.token !== state.token) {
+ res.writeHead(401, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
+ return;
+ }
+ // Defense in depth: manual copy edits must use the staged stash/apply
+ // endpoints. The direct Save event path is disabled in the browser.
+ if (msg.type === 'manual_edits') {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'manual_edits must POST to /manual-edit-stash, not /events' }));
+ return;
+ }
+ if (msg.type === 'manual_edit_apply') {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'manual_edit_apply is disabled; use /manual-edit-stash then /manual-edit-commit' }));
+ return;
+ }
+ const error = validateEvent(msg);
+ if (error) {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error }));
+ return;
+ }
+ if (state.sessionStore && msg.id) {
+ try {
+ state.sessionStore.appendEvent(msg);
+ } catch (err) {
+ res.writeHead(500, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'session_store_append_failed', message: err.message }));
+ return;
+ }
+ }
+ if (msg.type !== 'checkpoint') {
+ enqueueEvent(msg);
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true }));
+ });
+ return;
+ }
+
+ // --- Stop ---
+ if (p === '/stop') {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) { res.writeHead(401); res.end('Unauthorized'); return; }
+ res.writeHead(200, { 'Content-Type': 'text/plain' });
+ res.end('stopping');
+ shutdown();
+ return;
+ }
+
+ // --- Agent poll ---
+ if (p === '/poll' && req.method === 'GET') {
+ handlePollGet(req, res, url);
+ return;
+ }
+ if (p === '/poll' && req.method === 'POST') {
+ handlePollPost(req, res);
+ return;
+ }
+
+ res.writeHead(404); res.end('Not found');
+ };
+}
+
+// ---------------------------------------------------------------------------
+// Agent poll endpoints (unchanged from WS version)
+// ---------------------------------------------------------------------------
+
+function handlePollGet(req, res, url) {
+ const token = url.searchParams.get('token');
+ if (token !== state.token) {
+ res.writeHead(401, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
+ return;
+ }
+ state.lastPollAt = Date.now();
+ const timeout = parseInt(url.searchParams.get('timeout') || DEFAULT_POLL_TIMEOUT, 10);
+ const leaseMs = parseInt(url.searchParams.get('leaseMs') || '30000', 10);
+ const available = findAvailablePendingEvent();
+ if (available) {
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(leaseEvent(available, leaseMs)));
+ return;
+ }
+ const poll = { resolve, leaseMs };
+ const timer = setTimeout(() => {
+ const idx = state.pendingPolls.indexOf(poll);
+ if (idx !== -1) state.pendingPolls.splice(idx, 1);
+ broadcastAgentPollingIfChanged();
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ type: 'timeout' }));
+ }, timeout);
+ function resolve(event) {
+ clearTimeout(timer);
+ state.lastPollAt = Date.now();
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(event));
+ }
+ state.pendingPolls.push(poll);
+ broadcastAgentPollingIfChanged();
+ scheduleLeaseFlush();
+ req.on('close', () => {
+ clearTimeout(timer);
+ const idx = state.pendingPolls.indexOf(poll);
+ if (idx !== -1) state.pendingPolls.splice(idx, 1);
+ broadcastAgentPollingIfChanged();
+ });
+}
+
+function handlePollPost(req, res) {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ let msg;
+ try { msg = JSON.parse(body); } catch {
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Invalid JSON' }));
+ return;
+ }
+ if (msg.token !== state.token) {
+ res.writeHead(401, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
+ return;
+ }
+ const pendingApplyDeferred = state.pendingApplyDeferreds.get(msg.id);
+ if (pendingApplyDeferred) {
+ const validation = validateManualApplyResultMessage(msg, pendingApplyDeferred);
+ if (!validation.ok) {
+ recordManualEditActivity('manual_edit_apply_reply_invalid', {
+ id: msg.id,
+ pageUrl: pendingApplyDeferred.pageUrl,
+ chunk: pendingApplyDeferred.event?.chunk || null,
+ repair: pendingApplyDeferred.event?.repair || null,
+ reason: validation.body?.reason || validation.body?.error || 'invalid_manual_apply_result',
+ status: msg.data?.status || null,
+ });
+ res.writeHead(400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(validation.body));
+ return;
+ }
+ recordManualEditActivity('manual_edit_apply_reply_received', {
+ id: msg.id,
+ pageUrl: pendingApplyDeferred.pageUrl,
+ chunk: pendingApplyDeferred.event?.chunk || null,
+ repair: pendingApplyDeferred.event?.repair || null,
+ status: validation.result.status,
+ appliedCount: validation.result.appliedEntryIds.length,
+ failed: summarizeManualApplyFailures(validation.result.failed),
+ fileCount: validation.result.files.length,
+ noteCount: validation.result.notes.length,
+ });
+ resolveApplyDeferred(msg.id, validation.result);
+ acknowledgePendingEvent(msg.id);
+ flushPendingPolls();
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true }));
+ return;
+ }
+ if (state.timedOutApplyIds.has(msg.id)) {
+ const rollback = rollbackTimedOutApplyReply(msg);
+ recordManualEditActivity('manual_edit_apply_stale_reply_rejected', {
+ id: msg.id,
+ rolledBackFileCount: rollback.rolledBackFiles?.length || 0,
+ rollbackFailureCount: rollback.rollbackFailures?.length || 0,
+ });
+ res.writeHead(409, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ error: 'stale_manual_edit_apply_reply', ...rollback }));
+ return;
+ }
+ const acknowledgedEvent = acknowledgePendingEvent(msg.id);
+ let skipJournalReply = false;
+ let existingSession = null;
+ if (!acknowledgedEvent && state.sessionStore && msg.id) {
+ try {
+ existingSession = state.sessionStore.getSnapshot(msg.id, { includeCompleted: true });
+ if (!existingSession?.updatedAt) existingSession = null;
+ skipJournalReply = existingSession?.phase === 'completed' || existingSession?.phase === 'discarded';
+ } catch { /* fall through and record the reply normally */ }
+ }
+ if (!acknowledgedEvent && !existingSession) {
+ recordManualEditActivity('manual_edit_poll_reply_unknown', {
+ id: msg.id || null,
+ type: msg.type || null,
+ });
+ res.writeHead(msg.id ? 404 : 400, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({
+ error: msg.id ? 'unknown_poll_reply_id' : 'missing_poll_reply_id',
+ id: msg.id,
+ }));
+ return;
+ }
+ if (state.sessionStore && msg.id && !skipJournalReply) {
+ try {
+ const eventType = msg.type === 'steer_done'
+ ? 'steer_done'
+ : msg.type === 'discard' || msg.type === 'discarded'
+ ? 'discarded'
+ : msg.type === 'complete'
+ ? 'complete'
+ : msg.type === 'error'
+ ? 'agent_error'
+ : 'agent_done';
+ state.sessionStore.appendEvent({
+ type: eventType,
+ id: msg.id,
+ file: msg.file,
+ message: msg.message,
+ sourceEventType: acknowledgedEvent?.type,
+ carbonize: msg.data?.carbonize === true,
+ });
+ } catch { /* keep reply path best-effort; browser still needs SSE */ }
+ }
+ flushPendingPolls();
+ // Forward the reply to the browser via SSE
+ broadcast({ type: msg.type || 'done', id: msg.id, message: msg.message, file: msg.file, data: msg.data });
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify({ ok: true }));
+ });
+}
+
+// ---------------------------------------------------------------------------
+// Lifecycle
+// ---------------------------------------------------------------------------
+
+let httpServer = null;
+
+function shutdown() {
+ removeLiveServerInfo(process.cwd());
+ if (state.leaseTimer) clearTimeout(state.leaseTimer);
+ state.leaseTimer = null;
+ if (state.sessionDir) {
+ try { fs.rmSync(state.sessionDir, { recursive: true, force: true }); } catch {}
+ }
+ for (const res of state.sseClients) { try { res.end(); } catch {} }
+ state.sseClients.clear();
+ for (const poll of state.pendingPolls) poll.resolve({ type: 'exit' });
+ state.pendingPolls.length = 0;
+ if (httpServer) httpServer.close();
+ process.exit(0);
+}
+
+// ---------------------------------------------------------------------------
+// Main
+// ---------------------------------------------------------------------------
+
+const args = process.argv.slice(2);
+
+if (args.includes('--help') || args.includes('-h')) {
+ console.log(`Usage: node live-server.mjs [options]
+
+Start the live variant mode server (zero dependencies).
+
+Commands:
+ (default) Start the server (foreground)
+ stop Stop the server and remove the injected live.js script tag
+ stop --keep-inject Stop the server only (leave the script tag in the HTML entry)
+
+Options:
+ --background Start detached, print connection JSON to stdout, then exit
+ --port=PORT Use a specific port (default: auto-detect starting at 8400)
+ --keep-inject Only with stop: skip live-inject.mjs --remove
+ --help Show this help
+
+Endpoints:
+ /live.js Browser script (element picker + variant cycling)
+ /detect.js Detection overlay (backwards compatible)
+ /modern-screenshot.js Vendored modern-screenshot UMD build (lazy-loaded by live.js)
+ /annotation POST raw image/png to stage a variant screenshot
+ /events SSE stream (server→browser) + POST (browser→server)
+ /poll Long-poll for agent CLI
+ /manual-edit-stash Stage browser copy edits
+ /manual-edit-commit Apply staged browser copy edits
+ /manual-edit-discard Discard staged browser copy edits
+ /source Raw source file reader (no-HMR fallback)
+ /status Durable recovery status (token-protected)
+ /health Health check`);
+ process.exit(0);
+}
+
+if (args.includes('stop')) {
+ const keepInject = args.includes('--keep-inject');
+ try {
+ const { info } = readLiveServerInfo(process.cwd()) || {};
+ const res = await fetch(`http://localhost:${info.port}/stop?token=${info.token}`);
+ if (res.ok) console.log(`Stopped live server on port ${info.port}.`);
+ } catch {
+ console.log('No running live server found.');
+ }
+ if (!keepInject) {
+ const injectPath = path.join(__dirname, 'live-inject.mjs');
+ try {
+ const out = execFileSync(process.execPath, [injectPath, '--remove'], {
+ encoding: 'utf-8',
+ cwd: process.cwd(),
+ });
+ const line = out.trim().split('\n').filter(Boolean).pop();
+ if (line) {
+ try {
+ const j = JSON.parse(line);
+ if (j.removed === true) {
+ console.log(`Removed live script tag from ${j.file}.`);
+ }
+ } catch {
+ /* ignore non-JSON lines */
+ }
+ }
+ } catch (err) {
+ const detail = err.stderr?.toString?.().trim?.()
+ || err.stdout?.toString?.().trim?.()
+ || err.message
+ || String(err);
+ console.warn(`Note: could not remove live script tag (${detail.split('\n')[0]})`);
+ }
+ }
+ process.exit(0);
+}
+
+// --background: spawn a detached child server, wait for it to be ready,
+// print the connection JSON, then exit. This keeps the startup command
+// simple (no shell backgrounding or chained commands).
+if (args.includes('--background')) {
+ const childArgs = args.filter(a => a !== '--background');
+ const child = spawn(process.execPath, [fileURLToPath(import.meta.url), ...childArgs], {
+ detached: true,
+ stdio: 'ignore',
+ cwd: process.cwd(),
+ });
+ child.unref();
+
+ // Poll for the PID file (the child writes it once the HTTP server is listening).
+ const deadline = Date.now() + 10_000;
+ while (Date.now() < deadline) {
+ try {
+ const { info } = readLiveServerInfo(process.cwd()) || {};
+ if (info.pid !== process.pid) {
+ // Output JSON so the agent can read port + token from stdout.
+ console.log(JSON.stringify(info));
+ process.exit(0);
+ }
+ } catch { /* not ready yet */ }
+ await new Promise(r => setTimeout(r, 200));
+ }
+ console.error('Timed out waiting for live server to start.');
+ process.exit(1);
+}
+
+// Check for existing session
+const existingRecord = readLiveServerInfo(process.cwd());
+if (existingRecord?.info) {
+ const existing = existingRecord.info;
+ try {
+ process.kill(existing.pid, 0);
+ console.error(`Live server already running on port ${existing.port} (pid ${existing.pid}).`);
+ console.error('Stop it first with: node ' + path.basename(fileURLToPath(import.meta.url)) + ' stop');
+ process.exit(1);
+ } catch {
+ try { fs.unlinkSync(existingRecord.path); } catch {}
+ }
+}
+
+state.token = randomUUID();
+state.sessionStore = createLiveSessionStore({ cwd: process.cwd() });
+rollbackManualApplyTransaction({
+ cwd: process.cwd(),
+ reason: 'manual_edit_server_start_recovered_abandoned_transaction',
+});
+restorePendingEventsFromStore();
+pruneStaleManualApplyEvidence(process.cwd());
+const portArg = args.find(a => a.startsWith('--port='));
+state.port = portArg ? parseInt(portArg.split('=')[1], 10) : await findOpenPort();
+// Annotation screenshots live in the project root so the agent's Read tool
+// doesn't trip a per-file permission prompt. Sessioned by token so concurrent
+// projects (or quick restarts) don't collide.
+const annotRoot = getLiveAnnotationsDir(process.cwd());
+fs.mkdirSync(annotRoot, { recursive: true });
+state.sessionDir = fs.mkdtempSync(path.join(annotRoot, 'session-'));
+
+const { detectScript, sessionPath, livePath } = loadBrowserScripts();
+httpServer = http.createServer(createRequestHandler({ detectScript, sessionPath, livePath }));
+
+httpServer.listen(state.port, '127.0.0.1', () => {
+ writeLiveServerInfo(process.cwd(), { pid: process.pid, port: state.port, token: state.token });
+ const url = `http://localhost:${state.port}`;
+ console.log(`\nImpeccable live server running on ${url}`);
+ console.log(`Token: ${state.token}\n`);
+ console.log(`Script: ${url}/live.js`);
+ console.log('Inject: managed by live-inject.mjs; Astro source tags use is:inline automatically.');
+ console.log(`Stop: node ${path.basename(fileURLToPath(import.meta.url))} stop`);
+});
+
+process.on('SIGINT', shutdown);
+process.on('SIGTERM', shutdown);
diff --git a/.claude/skills/impeccable/scripts/live-session-store.mjs b/.claude/skills/impeccable/scripts/live-session-store.mjs
new file mode 100644
index 0000000..7562e3d
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/live-session-store.mjs
@@ -0,0 +1,271 @@
+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,
+ 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.file ?? next.sourceFile;
+ next.arrivedVariants = event.arrivedVariants ?? (next.arrivedVariants ?? next.expectedVariants);
+ 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 ((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;
+ 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.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.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/.claude/skills/impeccable/scripts/live-status.mjs b/.claude/skills/impeccable/scripts/live-status.mjs
new file mode 100644
index 0000000..a7009cc
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/live-status.mjs
@@ -0,0 +1,61 @@
+#!/usr/bin/env node
+/**
+ * Print durable recovery status for Impeccable live sessions.
+ */
+
+import { createLiveSessionStore } from './live-session-store.mjs';
+import { readLiveServerInfo } from './impeccable-paths.mjs';
+import { manualApplyResumeHint } from './live-resume.mjs';
+
+function readServerInfo() {
+ return readLiveServerInfo(process.cwd())?.info || null;
+}
+
+async function fetchServerStatus(info) {
+ if (!info) return null;
+ try {
+ const res = await fetch(`http://localhost:${info.port}/status?token=${info.token}`);
+ if (!res.ok) return null;
+ return await res.json();
+ } catch {
+ return null;
+ }
+}
+
+export async function statusCli() {
+ const info = readServerInfo();
+ const server = await fetchServerStatus(info);
+ const store = createLiveSessionStore({ cwd: process.cwd() });
+ const activeSessions = store.listActiveSessions();
+ const manualApply = findPendingManualApply(server, activeSessions);
+ const payload = {
+ liveServer: server ? {
+ status: server.status,
+ port: server.port,
+ connectedClients: server.connectedClients,
+ agentPolling: server.agentPolling,
+ pendingEvents: server.pendingEvents,
+ } : null,
+ activeSessions: server?.activeSessions || activeSessions,
+ recoveryHint: manualApply
+ ? manualApplyResumeHint(manualApply)
+ : server
+ ? 'Run live-poll.mjs to continue pending work, or live-complete.mjs --id after manual cleanup.'
+ : 'Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.',
+ };
+ console.log(JSON.stringify(payload, null, 2));
+}
+
+function findPendingManualApply(server, activeSessions) {
+ const fromServer = server?.pendingEvents?.find((event) => event?.type === 'manual_edit_apply');
+ if (fromServer) return fromServer;
+ const fromSession = activeSessions
+ ?.map((session) => session.pendingEvent)
+ .find((event) => event?.type === 'manual_edit_apply');
+ return fromSession || null;
+}
+
+const _running = process.argv[1];
+if (_running?.endsWith('live-status.mjs') || _running?.endsWith('live-status.mjs/')) {
+ statusCli();
+}
diff --git a/.claude/skills/impeccable/scripts/live-wrap.mjs b/.claude/skills/impeccable/scripts/live-wrap.mjs
new file mode 100644
index 0000000..20cca08
--- /dev/null
+++ b/.claude/skills/impeccable/scripts/live-wrap.mjs
@@ -0,0 +1,842 @@
+/**
+ * CLI helper: find an element in source and wrap it in a variant container.
+ *
+ * Usage:
+ * npx impeccable wrap --id SESSION_ID --count N --query "hero-combined-left" [--file path]
+ *
+ * Searches project files for the element matching the query (class name, ID, or
+ * text snippet), wraps it with the variant scaffolding, and prints the file path
+ * + line range where the agent should insert variant HTML.
+ *
+ * This replaces 3-4 agent tool calls (grep + read + edit) with a single CLI call.
+ */
+
+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';
+
+const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
+
+export async function wrapCli() {
+ const args = process.argv.slice(2);
+
+ if (args.includes('--help') || args.includes('-h')) {
+ console.log(`Usage: impeccable wrap [options]
+
+Find an element in source and wrap it in a variant container.
+
+Required:
+ --id ID Session ID for the variant wrapper
+ --count N Number of expected variants (1-8)
+
+Element identification (at least one required):
+ --element-id ID HTML id attribute of the element
+ --classes A,B,C Comma- or space-separated CSS class names
+ --tag TAG Tag name (div, section, etc.)
+ --query TEXT Fallback: raw text to search for
+
+Optional:
+ --file PATH Source file to search in (skips auto-detection)
+ --text TEXT Picked element's textContent. Used to disambiguate when
+ classes/tag match multiple sibling elements (e.g. a list
+ of s with the same className). Pass the first ~80
+ chars of event.element.textContent.
+ --page-url URL Current page URL. Required when pending manual edits may
+ affect the picked source block. Pending edits are filtered
+ to this page so an edit on /a doesn't bleed into /b.
+ --help Show this help message
+
+Output (JSON):
+ { file, startLine, endLine, insertLine, commentSyntax }
+
+The agent should insert variant HTML at insertLine.`);
+ process.exit(0);
+ }
+
+ const id = argVal(args, '--id');
+ const count = parseInt(argVal(args, '--count') || '3');
+ const elementId = argVal(args, '--element-id');
+ const classes = argVal(args, '--classes');
+ const tag = argVal(args, '--tag');
+ const query = argVal(args, '--query');
+ const filePath = argVal(args, '--file');
+ const text = argVal(args, '--text');
+ const pageUrl = argVal(args, '--page-url');
+
+ if (!id) { console.error('Missing --id'); process.exit(1); }
+ if (!elementId && !classes && !query) {
+ console.error('Need at least one of: --element-id, --classes, --query');
+ process.exit(1);
+ }
+
+ // Build search queries in priority order (most specific first)
+ const queries = buildSearchQueries(elementId, classes, tag, query);
+
+ const genOpts = { cwd: process.cwd() };
+
+ // Find the source file. Generated files are excluded from auto-search so we
+ // don't silently write variants into a file the next build will wipe.
+ let targetFile = filePath;
+ let matchedQuery = null;
+ if (!targetFile) {
+ for (const q of queries) {
+ targetFile = findFileWithQuery(q, process.cwd(), genOpts);
+ if (targetFile) { matchedQuery = q; break; }
+ }
+ if (!targetFile) {
+ // Nothing in source. Did the element show up in a generated file? That
+ // tells the agent "fall back to the agent-driven flow" vs "element just
+ // doesn't exist in this project."
+ let generatedHit = null;
+ for (const q of queries) {
+ generatedHit = findFileWithQuery(q, process.cwd(), { ...genOpts, includeGenerated: true });
+ if (generatedHit) break;
+ }
+ if (generatedHit) {
+ console.error(JSON.stringify({
+ error: 'element_not_in_source',
+ fallback: 'agent-driven',
+ generatedMatch: path.relative(process.cwd(), generatedHit),
+ hint: 'Element found only in a generated file. See "Handle fallback" in live.md.',
+ }));
+ } else {
+ console.error(JSON.stringify({
+ error: 'element_not_found',
+ fallback: 'agent-driven',
+ hint: 'Element not found in any project file. It may be runtime-injected (JS component, etc.). See "Handle fallback" in live.md.',
+ }));
+ }
+ process.exit(1);
+ }
+ } else {
+ if (isGeneratedFile(targetFile, genOpts)) {
+ console.error(JSON.stringify({
+ error: 'file_is_generated',
+ fallback: 'agent-driven',
+ file: path.relative(process.cwd(), path.resolve(process.cwd(), targetFile)),
+ hint: 'Explicit --file points at a generated file. Writing here gets wiped by the next build. See "Handle fallback" in live.md.',
+ }));
+ process.exit(1);
+ }
+ matchedQuery = queries[0];
+ }
+
+ const content = fs.readFileSync(targetFile, 'utf-8');
+ const lines = content.split('\n');
+
+ // Find the element, trying each query in priority order. When `--text` is
+ // supplied, collect every candidate the queries surface and disambiguate
+ // by the picked element's textContent. Without `--text`, fall back to the
+ // legacy first-match behavior so unmodified callers keep working.
+ let match = null;
+ if (text) {
+ const candidates = [];
+ for (const q of queries) {
+ const all = findAllElements(lines, q, tag);
+ for (const c of all) {
+ if (!candidates.some((x) => x.startLine === c.startLine)) {
+ candidates.push(c);
+ }
+ }
+ // Once a more-specific query (ID, full className combo) yielded a unique
+ // result, stop — falling through to the loose tag+single-class query
+ // would readmit the siblings we just disambiguated past.
+ if (candidates.length === 1) break;
+ }
+ if (candidates.length === 0) {
+ console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
+ process.exit(1);
+ }
+ if (candidates.length === 1) {
+ match = candidates[0];
+ } else {
+ const filtered = filterByText(candidates, lines, text);
+ if (filtered.length === 1) {
+ match = filtered[0];
+ } else if (filtered.length === 0) {
+ // Source uses dynamic content (`
{title}
` etc.) so the
+ // browser-side textContent doesn't appear literally in source. Fall
+ // back to first-match rather than refusing — this is the same
+ // behavior unmodified callers see, just preserved.
+ match = candidates[0];
+ } else {
+ // Multiple candidates ALSO match the text. Truly ambiguous — refuse
+ // rather than pick wrong, and hand the agent the candidate locations
+ // so it can disambiguate by reading the file.
+ console.error(JSON.stringify({
+ error: 'element_ambiguous',
+ fallback: 'agent-driven',
+ file: path.relative(process.cwd(), targetFile),
+ candidates: filtered.map((c) => ({
+ startLine: c.startLine + 1,
+ endLine: c.endLine + 1,
+ })),
+ hint: 'Multiple source elements match both classes/tag and textContent. Pass --element-id, a more specific --text, or write the wrapper manually. See "Handle fallback" in live.md.',
+ }));
+ process.exit(1);
+ }
+ }
+ } else {
+ for (const q of queries) {
+ match = findElement(lines, q, tag);
+ if (match) break;
+ }
+ if (!match) {
+ console.error(JSON.stringify({ error: 'Found file but could not locate element in ' + targetFile + '. Searched for: ' + queries.join(', ') }));
+ process.exit(1);
+ }
+ }
+
+ const { startLine, endLine } = match;
+ const commentSyntax = detectCommentSyntax(targetFile);
+ const styleMode = detectStyleMode(targetFile);
+ const isJsx = commentSyntax.open === '{/*';
+ const indent = lines[startLine].match(/^(\s*)/)[1];
+
+ // Extract the original element. Reindent under the wrapper while preserving
+ // the relative depth between lines — `l.trimStart()` would strip ALL leading
+ // whitespace and collapse e.g. `` (6/8/6 spaces)
+ // to a single uniform indent, so on accept/discard the round-trip restores
+ // the inner element at its parent's depth instead of nested inside it.
+ // Strip only the COMMON minimum leading whitespace across the picked lines;
+ // `deindentContent` on the accept side already mirrors this convention.
+ let originalLines = lines.slice(startLine, endLine + 1);
+
+ // Buffer-aware "original" content: if the user has pending manual edits for
+ // this page whose originalText appears in the picked source range, apply
+ // them so the wrap block's "original" variant reflects what the user was
+ // looking at (their edited DOM), not the raw source. Source itself stays
+ // untouched here — only the wrap block's embedded "original" copy is
+ // adjusted. The pending edits remain in the buffer until committed.
+ //
+ // Apply buffered edits only when the browser provided the current page URL.
+ // Without it, fail if pending edits plausibly touch this exact source range;
+ // otherwise skip buffer awareness so unrelated staged edits on another page
+ // do not block normal wrap work.
+ let pendingBuffer = { entries: [] };
+ try { pendingBuffer = readManualEditsBuffer(process.cwd()); } catch {}
+ const pendingEntriesForTarget = pageUrl
+ ? []
+ : pendingEntriesThatMayAffectWrap(pendingBuffer.entries, targetFile, originalLines, startLine, process.cwd());
+ if (pendingEntriesForTarget.length > 0) {
+ console.error(JSON.stringify({
+ error: 'missing_page_url_with_pending_edits',
+ pendingEntries: pendingEntriesForTarget.length,
+ hint: 'Pending manual edits may affect the selected source block. Pass --page-url=$event.pageUrl so the wrap block reflects the user\'s staged DOM.',
+ }));
+ process.exit(1);
+ }
+ if (pageUrl) {
+ const failedBufferedOps = [];
+ for (const entry of pendingBuffer.entries || []) {
+ if (entry.pageUrl !== pageUrl) continue;
+ for (const op of entry.ops || []) {
+ const mayAffectWrap = manualEditMayAffectWrap(op, targetFile, originalLines, startLine, process.cwd());
+ const result = applyBufferedManualEditToLines(originalLines, startLine, op);
+ if (result.changed) {
+ originalLines = result.lines;
+ continue;
+ }
+ if (!mayAffectWrap) continue;
+ failedBufferedOps.push({
+ entryId: entry.id,
+ ref: op?.ref || null,
+ originalText: op?.originalText || null,
+ reason: 'ambiguous_or_unmatched_pending_edit',
+ });
+ }
+ }
+ if (failedBufferedOps.length > 0) {
+ console.error(JSON.stringify({
+ error: 'manual_edit_buffer_apply_failed',
+ pendingOps: failedBufferedOps,
+ hint: 'A staged copy edit appears to affect the selected source block, but could not be applied unambiguously to the wrap original. Apply or discard copy edits first, or write the wrapper manually.',
+ }));
+ process.exit(1);
+ }
+ }
+
+ const originalBaseIndent = minLeadingSpaces(originalLines);
+ const reindentOriginal = (extra) => originalLines
+ .map((l) => (l.trim() === '' ? '' : indent + extra + l.slice(originalBaseIndent)))
+ .join('\n');
+ const originalIndented = reindentOriginal(' ');
+
+ // Wrapper attributes differ by syntax. HTML allows plain string attrs;
+ // JSX requires object-literal style and parses string attrs as HTML (which
+ // either type-errors or renders a literal CSS string).
+ const styleContents = isJsx ? 'style={{ display: "contents" }}' : 'style="display: contents"';
+
+ // JSX/TSX guard: the picked element occupies a single JSX child slot
+ // (inside `return (...)`, an array `.map(...)`, an `asChild` branch, or
+ // any other expression position). Replacing it with `comment +
+
+ // comment` yields three adjacent siblings — invalid JSX. We can't use a
+ // Fragment `<>>` either: parents that clone children (Radix `asChild`,
+ // Headless UI, etc.) hit "Invalid prop supplied to React.Fragment" when
+ // they try to pass an `id` through.
+ //
+ // Solution: keep the wrapper `
` as the single JSX-slot child and
+ // tuck both marker comments INSIDE it. accept/discard then expands its
+ // replacement range to include the wrapper's `
` open / close lines
+ // so the entire scaffold gets removed cleanly.
+ const wrapperLines = isJsx ? [
+ indent + '