@cyber-dash-tech/revela 0.18.4 → 0.18.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  **English** | [中文](README.zh-CN.md)
4
4
 
5
- [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela) [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE) [![tests](https://img.shields.io/badge/tests-691%20passing-brightgreen)](tests/) [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai) [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](https://bun.sh)
5
+ [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela) [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE) [![tests](https://img.shields.io/badge/tests-709%20passing-brightgreen)](tests/) [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai) [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](https://bun.sh)
6
6
 
7
7
  <p align="center">
8
8
  <img src="assets/img/logo.png" alt="Revela" width="560" />
@@ -34,8 +34,8 @@ To install globally, add the same entry to `~/.config/opencode/opencode.json`.
34
34
  Requirements:
35
35
 
36
36
  - The Codex CLI must be installed and the `codex` command must be available in your shell.
37
- - Your environment must be able to run `npx`; Revela uses `npx -y @cyber-dash-tech/revela@0.18.4 mcp` to start the MCP server.
38
- - For interactive Review actions, `codex exec` must also work because the Review UI uses it for Comment/Apply Fix requests.
37
+ - Your environment must be able to run `npx`; Revela uses `npx -y @cyber-dash-tech/revela@0.18.6 mcp` to start the MCP server.
38
+ - For interactive Review Apply actions, `codex exec` must also work because the Review UI uses it after saved comments are applied.
39
39
 
40
40
  Optional preflight:
41
41
 
@@ -55,16 +55,18 @@ npm_config_cache=/tmp/revela-npm-cache bun run smoke:mcp-pack
55
55
  Install Revela through the Codex Git marketplace:
56
56
 
57
57
  ```bash
58
- codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.4
58
+ codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.6
59
59
  codex plugin add revela@revela
60
60
  ```
61
61
 
62
- The Git marketplace install provides the Codex plugin shell, skills, hooks, and MCP configuration. When Codex starts the Revela MCP server for the first time, it runs `npx -y @cyber-dash-tech/revela@0.18.4 mcp` so npm can fetch the published package and its dependencies.
62
+ The Git marketplace install provides the Codex plugin shell, skills, hooks, and MCP configuration. When Codex starts the Revela MCP server for the first time, it runs `npx -y @cyber-dash-tech/revela@0.18.6 mcp` so npm can fetch the published package and its dependencies.
63
63
 
64
64
  You do not need to run `bun install` inside the Codex marketplace clone.
65
65
 
66
66
  Start a new Codex thread after installing so Codex loads the Revela skills, MCP tools, and hooks.
67
67
 
68
+ Codex uses five Revela skills: `revela-helper` for status and active design/domain, `revela-research` for local and web research saved under `researches/` plus the design-aware `deck-plan.md` handoff, `revela-make-deck` for generating `decks/*.html` from an existing plan, `revela-review` for the Review UI, and `revela-export` for PDF/PPTX/PNG.
69
+
68
70
  For release-aligned local validation, run `bun run smoke:mcp-pack`. It packs the current checkout to a temporary npm tarball and starts the MCP server through `npx`, matching the published Codex launcher path without requiring a registry publish.
69
71
 
70
72
  #### Codex Upgrade
package/README.zh-CN.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  [English](README.md) | **中文**
4
4
 
5
- [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela) [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE) [![tests](https://img.shields.io/badge/tests-691%20passing-brightgreen)](tests/) [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai) [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](https://bun.sh)
5
+ [![npm version](https://img.shields.io/npm/v/@cyber-dash-tech/revela)](https://www.npmjs.com/package/@cyber-dash-tech/revela) [![license](https://img.shields.io/npm/l/@cyber-dash-tech/revela)](LICENSE) [![tests](https://img.shields.io/badge/tests-709%20passing-brightgreen)](tests/) [![OpenCode plugin](https://img.shields.io/badge/OpenCode-plugin-blue)](https://opencode.ai) [![Bun](https://img.shields.io/badge/Bun-%E2%89%A51.0-orange)](https://bun.sh)
6
6
 
7
7
  <p align="center">
8
8
  <img src="assets/img/logo.png" alt="Revela" width="560" />
@@ -34,8 +34,8 @@ Revela 可在 [OpenCode](https://opencode.ai) 和 Codex 中使用,把来源材
34
34
  环境要求:
35
35
 
36
36
  - 需要已安装 Codex CLI,并且 shell 中可以执行 `codex`。
37
- - 环境中需要可以执行 `npx`;Revela 会用 `npx -y @cyber-dash-tech/revela@0.18.4 mcp` 启动 MCP server。
38
- - 如果使用 Review UI 的 Comment 或 Apply Fix,需要 `codex exec` 可用。
37
+ - 环境中需要可以执行 `npx`;Revela 会用 `npx -y @cyber-dash-tech/revela@0.18.6 mcp` 启动 MCP server。
38
+ - 如果使用 Review UI 的 Apply,需要 `codex exec` 可用;评论会先保存,点击 Apply 后才执行修复。
39
39
 
40
40
  可选的安装前检查:
41
41
 
@@ -55,16 +55,18 @@ npm_config_cache=/tmp/revela-npm-cache bun run smoke:mcp-pack
55
55
  通过 Codex Git marketplace 安装 Revela:
56
56
 
57
57
  ```bash
58
- codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.4
58
+ codex plugin marketplace add https://github.com/cyber-dash-tech/revela --ref v0.18.6
59
59
  codex plugin add revela@revela
60
60
  ```
61
61
 
62
- Git marketplace 安装的是 Codex plugin 壳、skills、hooks 和 MCP 配置。Codex 第一次启动 Revela MCP server 时,会运行 `npx -y @cyber-dash-tech/revela@0.18.4 mcp`,由 npm 获取已发布 package 及其 dependencies。
62
+ Git marketplace 安装的是 Codex plugin 壳、skills、hooks 和 MCP 配置。Codex 第一次启动 Revela MCP server 时,会运行 `npx -y @cyber-dash-tech/revela@0.18.6 mcp`,由 npm 获取已发布 package 及其 dependencies。
63
63
 
64
64
  不需要在 Codex marketplace clone 里运行 `bun install`。
65
65
 
66
66
  安装后开启一个新的 Codex thread,让 Codex 加载 Revela 的 skills、MCP tools 和 hooks。
67
67
 
68
+ Codex 使用五个 Revela skills:`revela-helper` 查看状态和 active design/domain,`revela-research` 调研本地与网络资料、保存到 `researches/`,并产出 design-aware `deck-plan.md` handoff;`revela-make-deck` 基于已有 plan 生成 `decks/*.html`,`revela-review` 打开 Review UI,`revela-export` 导出 PDF/PPTX/PNG。
69
+
68
70
  如果要按发布路径做本地验证,运行 `bun run smoke:mcp-pack`。它会把当前 checkout 打成临时 npm tarball,再通过 `npx` 启动 MCP server,不需要先发布到 registry。
69
71
 
70
72
  #### Codex 升级
@@ -18,7 +18,7 @@ export async function handleRefine(
18
18
  `File: \`${result.deck.file}\`\n` +
19
19
  `${result.stateNote}\n` +
20
20
  `URL: ${result.url}\n\n` +
21
- `Use Ctrl/Cmd-click in the browser to reference deck elements. The Comment tab sends targeted change comments and applies deck edits after artifact QA.`
21
+ `Use Ctrl/Cmd-click in the browser to reference deck elements. The Comment tab saves targeted comments; use Apply on a saved comment to run deck edits.`
22
22
  )
23
23
  } catch (e: any) {
24
24
  await send(`**Review failed:** ${e.message || String(e)}`)
@@ -13,7 +13,7 @@ export interface PendingCommentRequest {
13
13
  raw?: string
14
14
  }
15
15
 
16
- const REQUEST_TTL_MS = 120 * 1000
16
+ const REQUEST_TTL_MS = 360 * 1000
17
17
  const requests = new Map<string, PendingCommentRequest>()
18
18
  const subscribers = new Map<string, Set<(event: ReviewBridgeEvent) => void>>()
19
19
 
@@ -36,6 +36,9 @@ export interface CodexExecRunResult {
36
36
  stderr: string
37
37
  }
38
38
 
39
+ const DEFAULT_COMMENT_TIMEOUT_MS = 300_000
40
+ const DEFAULT_INSPECT_TIMEOUT_MS = 120_000
41
+
39
42
  export type CodexExecRunner = (input: {
40
43
  action: ReviewPromptAction
41
44
  prompt: string
@@ -71,39 +74,57 @@ export function createOpenCodeReviewPromptBridge(client: any, sessionID: string)
71
74
  export function createCodexExecReviewPromptBridge(options: {
72
75
  runner?: CodexExecRunner
73
76
  timeoutMs?: number
77
+ heartbeatMs?: number
74
78
  } = {}): ReviewPromptBridge {
75
79
  const runner = options.runner ?? runCodexExec
76
- const timeoutMs = options.timeoutMs ?? 120_000
80
+ const heartbeatMs = options.heartbeatMs ?? 10_000
77
81
  return {
78
82
  kind: "codex-exec",
79
83
  async send(input) {
80
84
  const sandboxMode = input.action === "comment" ? "workspace-write" : "read-only"
85
+ const timeoutMs = input.timeoutMs ?? options.timeoutMs ?? (input.action === "comment" ? DEFAULT_COMMENT_TIMEOUT_MS : DEFAULT_INSPECT_TIMEOUT_MS)
81
86
  input.onEvent?.(bridgeEvent("started", "Starting Codex..."))
82
- const output = await runner({
83
- action: input.action,
84
- prompt: input.prompt,
85
- workspaceRoot: input.workspaceRoot,
86
- timeoutMs: input.timeoutMs ?? timeoutMs,
87
- sandboxMode,
88
- skipGitRepoCheck: true,
89
- onEvent: input.onEvent,
90
- })
87
+ const startedAt = Date.now()
88
+ const heartbeat = input.onEvent
89
+ ? setInterval(() => {
90
+ const elapsedSeconds = Math.max(1, Math.round((Date.now() - startedAt) / 1000))
91
+ input.onEvent?.(bridgeEvent("codex_event", "Codex is still working...", `elapsedSeconds=${elapsedSeconds}`))
92
+ }, heartbeatMs)
93
+ : undefined
94
+ let output: CodexExecRunResult
95
+ try {
96
+ output = await runner({
97
+ action: input.action,
98
+ prompt: input.prompt,
99
+ workspaceRoot: input.workspaceRoot,
100
+ timeoutMs,
101
+ sandboxMode,
102
+ skipGitRepoCheck: true,
103
+ onEvent: input.onEvent,
104
+ })
105
+ } finally {
106
+ if (heartbeat) clearInterval(heartbeat)
107
+ }
91
108
  const raw = [output.stdout, output.stderr].filter(Boolean).join("\n")
92
- if (output.exitCode !== 0) {
93
- input.onEvent?.(bridgeEvent("failed", `codex exec failed with exit code ${output.exitCode ?? "unknown"}.`, boundedTail(raw)))
109
+ if (input.action === "comment" && isCodexWriteBlocked(raw)) {
110
+ input.onEvent?.(bridgeEvent("failed", "codex exec could not write the deck because its sandbox blocked file changes.", boundedTail(raw)))
94
111
  return {
95
112
  ok: false,
96
113
  status: "failed",
97
- error: `codex exec failed with exit code ${output.exitCode ?? "unknown"}.`,
114
+ error: "codex exec could not write the deck because its sandbox blocked file changes.",
98
115
  raw,
99
116
  }
100
117
  }
101
- if (input.action === "comment" && isCodexWriteBlocked(raw)) {
102
- input.onEvent?.(bridgeEvent("failed", "codex exec could not write the deck because its sandbox blocked file changes.", boundedTail(raw)))
118
+ if (output.exitCode !== 0) {
119
+ if (input.action === "comment" && output.exitCode === 124 && hasTrustedCodexCompletion(output.stdout)) {
120
+ input.onEvent?.(bridgeEvent("completed", "Codex completed."))
121
+ return { ok: true, status: "completed", raw }
122
+ }
123
+ input.onEvent?.(bridgeEvent("failed", `codex exec failed with exit code ${output.exitCode ?? "unknown"}.`, boundedTail(raw)))
103
124
  return {
104
125
  ok: false,
105
126
  status: "failed",
106
- error: "codex exec could not write the deck because its sandbox blocked file changes.",
127
+ error: `codex exec failed with exit code ${output.exitCode ?? "unknown"}.`,
107
128
  raw,
108
129
  }
109
130
  }
@@ -146,6 +167,7 @@ async function runCodexExec(input: {
146
167
  let stdout = ""
147
168
  let stderr = ""
148
169
  let stdoutLineBuffer = ""
170
+ let sawTrustedCompletion = false
149
171
  let resolved = false
150
172
  const resolveOnce = (output: CodexExecRunResult) => {
151
173
  if (resolved) return
@@ -155,7 +177,11 @@ async function runCodexExec(input: {
155
177
  const timer = setTimeout(() => {
156
178
  child.kill()
157
179
  const nextStderr = `${stderr}${stderr ? "\n" : ""}codex exec timed out after ${input.timeoutMs}ms.`
158
- input.onEvent?.(bridgeEvent("timeout", "Codex timed out before completing.", boundedTail(nextStderr)))
180
+ if (input.action === "comment" && sawTrustedCompletion) {
181
+ input.onEvent?.(bridgeEvent("completed", "Codex completed."))
182
+ } else {
183
+ input.onEvent?.(bridgeEvent("timeout", "Codex timed out before completing.", boundedTail(nextStderr)))
184
+ }
159
185
  resolveOnce({
160
186
  exitCode: 124,
161
187
  stdout,
@@ -165,7 +191,9 @@ async function runCodexExec(input: {
165
191
  child.stdout?.on("data", (chunk) => {
166
192
  const text = chunk.toString()
167
193
  stdout += text
168
- stdoutLineBuffer = emitCodexJsonProgress(stdoutLineBuffer + text, input.action, input.onEvent)
194
+ const progress = emitCodexJsonProgress(stdoutLineBuffer + text, input.action, input.onEvent)
195
+ stdoutLineBuffer = progress.remainder
196
+ sawTrustedCompletion = sawTrustedCompletion || progress.sawTrustedCompletion
169
197
  })
170
198
  child.stderr?.on("data", (chunk) => {
171
199
  const text = chunk.toString()
@@ -179,17 +207,20 @@ async function runCodexExec(input: {
179
207
  })
180
208
  child.on("close", (code) => {
181
209
  clearTimeout(timer)
182
- emitCodexJsonProgress(`${stdoutLineBuffer}\n`, input.action, input.onEvent)
210
+ const progress = emitCodexJsonProgress(`${stdoutLineBuffer}\n`, input.action, input.onEvent)
211
+ sawTrustedCompletion = sawTrustedCompletion || progress.sawTrustedCompletion
183
212
  resolveOnce({ exitCode: code, stdout, stderr })
184
213
  })
185
214
  })
186
215
  }
187
216
 
188
- function emitCodexJsonProgress(buffer: string, action: ReviewPromptAction, onEvent?: (event: ReviewBridgeEvent) => void): string {
217
+ function emitCodexJsonProgress(buffer: string, action: ReviewPromptAction, onEvent?: (event: ReviewBridgeEvent) => void): { remainder: string; sawTrustedCompletion: boolean } {
189
218
  const lines = buffer.split(/\r?\n/)
190
219
  const remainder = lines.pop() ?? ""
220
+ let sawTrustedCompletion = false
191
221
  for (const line of lines) {
192
222
  const parsed = parseJson(line)
223
+ sawTrustedCompletion = sawTrustedCompletion || isTrustedCodexCompletionRecord(parsed)
193
224
  const message = codexProgressMessage(parsed, action)
194
225
  if (message) {
195
226
  onEvent?.(bridgeEvent("codex_event", message, boundedTail(line)))
@@ -197,7 +228,7 @@ function emitCodexJsonProgress(buffer: string, action: ReviewPromptAction, onEve
197
228
  onEvent?.(bridgeEvent("stdout", "Codex wrote output.", boundedTail(line)))
198
229
  }
199
230
  }
200
- return remainder
231
+ return { remainder, sawTrustedCompletion }
201
232
  }
202
233
 
203
234
  function codexProgressMessage(value: unknown, action: ReviewPromptAction): string | undefined {
@@ -207,14 +238,18 @@ function codexProgressMessage(value: unknown, action: ReviewPromptAction): strin
207
238
  const event = typeof record.event === "string" ? record.event.toLowerCase() : ""
208
239
  const name = `${type} ${event}`
209
240
  if (!name.trim()) return undefined
210
- if (name.includes("turn_completed") || name.includes("completed")) {
241
+ const normalized = name.replace(/[._-]+/g, " ")
242
+ if (normalized.includes("turn completed") || normalized.includes("thread completed") || normalized.includes("completed")) {
211
243
  return action === "comment" ? undefined : "Codex completed the inspection."
212
244
  }
213
- if (name.includes("exec") || name.includes("patch") || name.includes("tool") || name.includes("apply")) {
245
+ if (normalized.includes("thread started") || normalized.includes("turn started") || normalized.includes("session started")) {
246
+ return "Codex started reading the deck..."
247
+ }
248
+ if (normalized.includes("exec") || normalized.includes("patch") || normalized.includes("tool") || normalized.includes("apply")) {
214
249
  return action === "comment" ? "Codex is applying the requested edit..." : "Codex is reading the deck..."
215
250
  }
216
- if (name.includes("session") || name.includes("turn") || name.includes("start")) return "Codex is reading the deck..."
217
- if (name.includes("message") || name.includes("delta") || name.includes("agent")) return "Codex is working..."
251
+ if (normalized.includes("session") || normalized.includes("thread") || normalized.includes("turn") || normalized.includes("start")) return "Codex is reading the deck..."
252
+ if (normalized.includes("message") || normalized.includes("delta") || normalized.includes("agent")) return "Codex is working..."
218
253
  return "Codex is working..."
219
254
  }
220
255
 
@@ -227,6 +262,40 @@ function boundedTail(text: string, limit = 4096): string {
227
262
  return text.slice(text.length - limit)
228
263
  }
229
264
 
265
+ function hasTrustedCodexCompletion(stdout: string): boolean {
266
+ for (const line of stdout.split(/\r?\n/)) {
267
+ const parsed = parseJson(line)
268
+ if (isTrustedCodexCompletionRecord(parsed)) return true
269
+ }
270
+ for (const block of extractJsonBlocks(stdout)) {
271
+ const parsed = parseJson(block)
272
+ if (isTrustedCodexCompletionRecord(parsed)) return true
273
+ }
274
+ return false
275
+ }
276
+
277
+ function isTrustedCodexCompletionRecord(value: unknown): boolean {
278
+ if (!value) return false
279
+ if (typeof value === "string") return isTrustedCodexCompletionRecord(parseJson(value))
280
+ if (Array.isArray(value)) return value.some((item) => isTrustedCodexCompletionRecord(item))
281
+ if (typeof value !== "object") return false
282
+
283
+ const record = value as Record<string, unknown>
284
+ const type = typeof record.type === "string" ? record.type.toLowerCase() : ""
285
+ const event = typeof record.event === "string" ? record.event.toLowerCase() : ""
286
+ const status = typeof record.status === "string" ? record.status.toLowerCase() : ""
287
+ const normalized = `${type} ${event}`.replace(/[._-]+/g, " ")
288
+ const exitCode = typeof record.exit_code === "number" ? record.exit_code : typeof record.exitCode === "number" ? record.exitCode : undefined
289
+
290
+ if (normalized.includes("turn completed") || normalized.includes("thread completed")) return true
291
+ if (status === "completed" && exitCode === 0) return true
292
+
293
+ for (const key of ["item", "result", "output", "event", "payload"]) {
294
+ if (isTrustedCodexCompletionRecord(record[key])) return true
295
+ }
296
+ return false
297
+ }
298
+
230
299
  function isCodexWriteBlocked(raw: string): boolean {
231
300
  const text = raw.toLowerCase()
232
301
  return (
@@ -0,0 +1,203 @@
1
+ import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs"
2
+ import { join } from "path"
3
+ import { randomBytes } from "crypto"
4
+ import { workspaceMetaPath } from "../workspace-meta"
5
+
6
+ export type ReviewCommentStatus = "open" | "queued" | "applying" | "applied" | "failed"
7
+
8
+ export interface ReviewCommentRecord {
9
+ id: string
10
+ deckFile: string
11
+ slideIndex: number
12
+ deckVersion: string
13
+ comment: string
14
+ elements: any[]
15
+ asset?: any
16
+ drop?: any
17
+ status: ReviewCommentStatus
18
+ createdAt: string
19
+ updatedAt: string
20
+ lastApplyRequestId?: string
21
+ lastApplyError?: string
22
+ lastApplyRaw?: string
23
+ }
24
+
25
+ export interface ReviewCommentCreateInput {
26
+ deckFile: string
27
+ deckVersion: string
28
+ comment: string
29
+ elements: any[]
30
+ asset?: any
31
+ drop?: any
32
+ }
33
+
34
+ export function createReviewComment(workspaceRoot: string, input: ReviewCommentCreateInput): ReviewCommentRecord {
35
+ const comment = input.comment.trim()
36
+ if (!comment) throw new Error("Comment is required")
37
+ const elements = Array.isArray(input.elements) ? input.elements : []
38
+ const slideIndex = deriveSlideIndex(elements)
39
+ const now = new Date().toISOString()
40
+ const record: ReviewCommentRecord = {
41
+ id: randomBytes(10).toString("base64url"),
42
+ deckFile: normalizeDeckFile(input.deckFile),
43
+ slideIndex,
44
+ deckVersion: input.deckVersion,
45
+ comment,
46
+ elements,
47
+ ...(input.asset ? { asset: input.asset } : {}),
48
+ ...(input.drop ? { drop: input.drop } : {}),
49
+ status: "open",
50
+ createdAt: now,
51
+ updatedAt: now,
52
+ }
53
+ writeReviewComment(workspaceRoot, record)
54
+ return record
55
+ }
56
+
57
+ export function listReviewComments(workspaceRoot: string, deckFile: string): ReviewCommentRecord[] {
58
+ const file = normalizeDeckFile(deckFile)
59
+ const dir = reviewCommentsDir(workspaceRoot)
60
+ if (!existsSync(dir)) return []
61
+ const registry = readRegistry(workspaceRoot)
62
+ const records = registry.commentIds
63
+ .map((id) => readReviewComment(workspaceRoot, id))
64
+ .filter((record): record is ReviewCommentRecord => Boolean(record))
65
+ return records
66
+ .filter((record) => record.deckFile === file)
67
+ .sort((a, b) => a.slideIndex - b.slideIndex || a.createdAt.localeCompare(b.createdAt))
68
+ }
69
+
70
+ export function readReviewComment(workspaceRoot: string, id: string): ReviewCommentRecord | undefined {
71
+ const safeId = normalizeId(id)
72
+ if (!safeId) return undefined
73
+ const path = reviewCommentPath(workspaceRoot, safeId)
74
+ if (!existsSync(path)) return undefined
75
+ return JSON.parse(readFileSync(path, "utf-8")) as ReviewCommentRecord
76
+ }
77
+
78
+ export function markReviewCommentApplying(workspaceRoot: string, id: string, requestId: string): ReviewCommentRecord | undefined {
79
+ return updateReviewComment(workspaceRoot, id, (record) => ({
80
+ ...record,
81
+ status: "applying",
82
+ lastApplyRequestId: requestId,
83
+ lastApplyError: undefined,
84
+ lastApplyRaw: undefined,
85
+ }))
86
+ }
87
+
88
+ export function markReviewCommentQueued(workspaceRoot: string, id: string): ReviewCommentRecord | undefined {
89
+ return updateReviewComment(workspaceRoot, id, (record) => ({
90
+ ...record,
91
+ status: "queued",
92
+ lastApplyError: undefined,
93
+ lastApplyRaw: undefined,
94
+ }))
95
+ }
96
+
97
+ export function markReviewCommentApplied(workspaceRoot: string, id: string): ReviewCommentRecord | undefined {
98
+ return updateReviewComment(workspaceRoot, id, (record) => ({
99
+ ...record,
100
+ status: "applied",
101
+ lastApplyError: undefined,
102
+ lastApplyRaw: undefined,
103
+ }))
104
+ }
105
+
106
+ export function markReviewCommentFailed(workspaceRoot: string, id: string, error: string, raw?: string): ReviewCommentRecord | undefined {
107
+ return updateReviewComment(workspaceRoot, id, (record) => ({
108
+ ...record,
109
+ status: "failed",
110
+ lastApplyError: error,
111
+ ...(raw ? { lastApplyRaw: boundedTail(raw) } : {}),
112
+ }))
113
+ }
114
+
115
+ export function markReviewCommentStopped(workspaceRoot: string, id: string): ReviewCommentRecord | undefined {
116
+ return markReviewCommentFailed(workspaceRoot, id, "Stopped by user.", "Stopped by user.")
117
+ }
118
+
119
+ export function deleteReviewComment(workspaceRoot: string, id: string): boolean {
120
+ const safeId = normalizeId(id)
121
+ if (!safeId) return false
122
+ const path = reviewCommentPath(workspaceRoot, safeId)
123
+ if (!existsSync(path)) return false
124
+ unlinkSync(path)
125
+ const registry = readRegistry(workspaceRoot)
126
+ writeRegistry(workspaceRoot, {
127
+ version: 1,
128
+ commentIds: registry.commentIds.filter((item) => item !== safeId),
129
+ })
130
+ return true
131
+ }
132
+
133
+ function updateReviewComment(
134
+ workspaceRoot: string,
135
+ id: string,
136
+ update: (record: ReviewCommentRecord) => ReviewCommentRecord,
137
+ ): ReviewCommentRecord | undefined {
138
+ const record = readReviewComment(workspaceRoot, id)
139
+ if (!record) return undefined
140
+ const next = { ...update(record), updatedAt: new Date().toISOString() }
141
+ writeReviewComment(workspaceRoot, next)
142
+ return next
143
+ }
144
+
145
+ function writeReviewComment(workspaceRoot: string, record: ReviewCommentRecord): void {
146
+ const registry = readRegistry(workspaceRoot)
147
+ const dir = reviewCommentsDir(workspaceRoot)
148
+ mkdirSync(dir, { recursive: true })
149
+ writeFileSync(reviewCommentPath(workspaceRoot, record.id), `${JSON.stringify(record, null, 2)}\n`, "utf-8")
150
+ if (!registry.commentIds.includes(record.id)) {
151
+ registry.commentIds.push(record.id)
152
+ writeRegistry(workspaceRoot, registry)
153
+ }
154
+ }
155
+
156
+ function readRegistry(workspaceRoot: string): { version: 1; commentIds: string[] } {
157
+ const path = join(reviewCommentsDir(workspaceRoot), "registry.json")
158
+ if (!existsSync(path)) return { version: 1, commentIds: [] }
159
+ const parsed = JSON.parse(readFileSync(path, "utf-8")) as { version?: number; commentIds?: unknown[] }
160
+ return {
161
+ version: 1,
162
+ commentIds: Array.isArray(parsed.commentIds) ? parsed.commentIds.filter((item): item is string => typeof item === "string") : [],
163
+ }
164
+ }
165
+
166
+ function writeRegistry(workspaceRoot: string, registry: { version: 1; commentIds: string[] }): void {
167
+ const dir = reviewCommentsDir(workspaceRoot)
168
+ mkdirSync(dir, { recursive: true })
169
+ writeFileSync(join(dir, "registry.json"), `${JSON.stringify(registry, null, 2)}\n`, "utf-8")
170
+ }
171
+
172
+ function reviewCommentsDir(workspaceRoot: string): string {
173
+ return workspaceMetaPath(workspaceRoot, "review-comments")
174
+ }
175
+
176
+ function reviewCommentPath(workspaceRoot: string, id: string): string {
177
+ return join(reviewCommentsDir(workspaceRoot), `${id}.json`)
178
+ }
179
+
180
+ function normalizeDeckFile(file: string): string {
181
+ return file.replace(/\\/g, "/").replace(/^\.\//, "")
182
+ }
183
+
184
+ function normalizeId(id: string): string {
185
+ const trimmed = id.trim()
186
+ return /^[A-Za-z0-9_-]+$/.test(trimmed) ? trimmed : ""
187
+ }
188
+
189
+ function deriveSlideIndex(elements: any[]): number {
190
+ const indexes = new Set<number>()
191
+ for (const element of elements) {
192
+ const value = element?.slideIndex
193
+ if (Number.isInteger(value) && value > 0) indexes.add(value)
194
+ }
195
+ if (indexes.size === 0) throw new Error("Comment must reference one slide. Ctrl/Cmd-click an element on the target slide first.")
196
+ if (indexes.size > 1) throw new Error("Comment references multiple slides. Split this into per-slide comments before applying fixes.")
197
+ return [...indexes][0]
198
+ }
199
+
200
+ function boundedTail(text: string, limit = 4096): string {
201
+ if (text.length <= limit) return text
202
+ return text.slice(text.length - limit)
203
+ }