@cyber-dash-tech/revela 0.10.0 → 0.11.0

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,14 +2,14 @@
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-178%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-302%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="800" />
9
9
  </p>
10
10
 
11
- Revela is an [OpenCode](https://opencode.ai) plugin that turns your current agent into an HTML slide deck generator.
12
- Enable it for the current session, assign a presentation task, and the agent can research, structure, write, QA, and export a deck.
11
+ Revela is an [OpenCode](https://opencode.ai) plugin for building trusted narrative artifacts from workspace sources, research, evidence, and user intent.
12
+ Its first render target is still the HTML slide deck: enable Revela for the current session, assign a presentation task, and the agent can research, structure, write, QA, inspect, refine, and export a deck.
13
13
 
14
14
  **[Live Demo — The AI Power Shift](https://cyber-dash-tech.github.io/revela/assets/html/ai-power-shift.html)**
15
15
 
@@ -20,8 +20,10 @@ Enable it for the current session, assign a presentation task, and the agent can
20
20
  - injects a presentation-specific system prompt into your current agent with `/revela enable`
21
21
  - builds that prompt from 3 layers: core skill, active domain, active design
22
22
  - supports workspace document discovery, transparent text extraction for `.pdf`, `.docx`, `.pptx`, and `.xlsx`, and cached embedded-material extraction for those formats
23
- - keeps track of deck context, slide structure, sources, and readiness across the current workspace
23
+ - keeps `DECKS.json` as the current workspace state engine for sources, research actions, findings, claims, evidence, narrative intent, render targets, and readiness
24
24
  - checks for missing context, weak evidence, and incomplete structure before writing `decks/*.html`
25
+ - records review snapshots so stale readiness cannot silently authorize new deck HTML after important state changes
26
+ - treats HTML decks, PDF, and PPTX as render targets from shared workspace state rather than isolated output files
25
27
  - runs fast design compliance checks whenever the agent writes, patches, or edits `decks/*.html`
26
28
  - opens a visual comment editor for existing decks so users can Ctrl/Cmd-click elements and send precise edit requests back to OpenCode
27
29
  - exports finished decks to PDF and editable PPTX
@@ -178,6 +180,22 @@ That prompt is built from 3 layers:
178
180
  Persistent preferences live in `~/.config/revela/config.json`.
179
181
  The enabled or disabled state is session-level only.
180
182
 
183
+ ### Workspace State
184
+
185
+ `DECKS.json` is Revela's workspace state engine and compatibility file. It is still stored at the workspace root and remains readable as the current deck project state, but internally Revela now treats it as a lightweight persistence layer for more than a deck checklist.
186
+
187
+ The state records:
188
+
189
+ - workspace source materials and reusable extraction cache paths
190
+ - research plans, saved findings, and compact action provenance
191
+ - narrative intent, objections, risks, slide specs, claim candidates, and slide-level evidence trace
192
+ - render targets such as the active HTML deck plus derived PDF and PPTX artifacts
193
+ - review snapshots with input hashes so old readiness results become stale after meaningful state changes
194
+
195
+ 0.11 keeps backward compatibility with existing root `DECKS.json` workspaces. Running `/revela init` or `/revela review` on an older project can normalize and refresh the new projection fields without requiring a manual migration, moving files, or replacing `DECKS.json` with a database.
196
+
197
+ Decks remain the primary authored artifact, but they are now treated as render targets from the same workspace state that can later support briefs, appendix material, Evidence Inspector views, Q&A, and interactive reading layers without duplicating source/evidence logic.
198
+
181
199
  ---
182
200
 
183
201
  ## Recommended Workflow
@@ -196,7 +214,7 @@ Use Revela as a guided deck-production mode:
196
214
 
197
215
  `/revela review` checks for practical readiness problems: unclear audience, missing source material, unfinished research, unsupported claims, weak source trace, incomplete slide structure, missing design/layout information, or lightweight narrative issues such as weak so-what, missing risk/assumption handling, and abrupt transitions. It does not write the final deck.
198
216
 
199
- If Revela blocks a deck write, ask the agent to run `/revela review`, resolve the reported gaps, and try again. This protects the deck file from being overwritten before the plan, evidence, and structure are ready.
217
+ If Revela blocks a deck write, ask the agent to run `/revela review`, resolve the reported gaps, and try again. This protects the deck file from being overwritten before the plan, evidence, and structure are ready. In 0.11, review results are also saved as input-hashed snapshots; if sources, slide specs, evidence, narrative state, or the active render target changes afterward, the old review can no longer silently authorize a write.
200
218
 
201
219
  To remember long-term preferences, use:
202
220
 
@@ -597,6 +615,8 @@ The inspector opens in your browser with the deck on the left and fixed cards on
597
615
 
598
616
  The inspector is not chat and has no freeform prompt. It does not mutate `DECKS.json` or the deck HTML. It uses recorded slide specs, narrative state, and slide-level evidence trace as grounded context. Deterministic preprocessing appears immediately; lazy LLM judgment then refines the Source and Purpose cards without inventing edits.
599
617
 
618
+ Inspection and refinement use the active HTML deck render target recorded in workspace state. The deck HTML must satisfy Revela's slide identity contract: every `<section class="slide">` in the active artifact needs a positive 1-based `data-slide-index` matching the current slide specs. Invalid active artifacts are refused or reported before inspect/refine/export workflows trust them.
619
+
600
620
  ---
601
621
 
602
622
  ## Export
package/README.zh-CN.md CHANGED
@@ -2,14 +2,14 @@
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-178%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-302%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="800" />
9
9
  </p>
10
10
 
11
- Revela 是一个 [OpenCode](https://opencode.ai) 插件,可以把你当前使用的 agent 变成 HTML 幻灯片生成器。
12
- 在当前会话中启用之后,agent 可以完成调研、结构设计、HTML 写作、QA 和导出。
11
+ Revela 是一个 [OpenCode](https://opencode.ai) 插件,用来把工作区来源材料、调研、证据和用户意图转成可信的叙事型沟通 artifact。
12
+ 它的第一个 render target 仍然是 HTML slide deck:在当前会话中启用之后,agent 可以完成调研、结构设计、HTML 写作、QA、检查、refine 和导出。
13
13
 
14
14
  **[在线演示 — AI 权力转移](https://cyber-dash-tech.github.io/revela/assets/html/ai-power-shift.html)**
15
15
 
@@ -20,8 +20,10 @@ Revela 是一个 [OpenCode](https://opencode.ai) 插件,可以把你当前使
20
20
  - 通过 `/revela enable` 向当前 agent 注入演示文稿专用 system prompt
21
21
  - prompt 由 3 层组成:核心 skill、当前 domain、当前 design
22
22
  - 支持工作区文档扫描,以及 `.pdf`、`.docx`、`.pptx`、`.xlsx` 的透明文本提取和嵌入素材缓存提取
23
- - 在当前工作区持续跟踪 deck 背景、页面结构、来源材料和 readiness
23
+ - `DECKS.json` 作为当前 workspace state engine,持续记录来源材料、调研动作、findings、claims、证据、叙事意图、render targets 和 readiness
24
24
  - 写入 `decks/*.html` 前检查是否缺上下文、证据或结构
25
+ - 记录 review snapshots,避免重要状态变化后旧的 ready 结果继续默默授权写入 deck HTML
26
+ - 把 HTML deck、PDF 和 PPTX 视为来自同一 workspace state 的 render targets,而不是互相孤立的输出文件
25
27
  - agent 每次写入、patch 或 edit `decks/*.html` 时自动执行快速 design compliance 检查
26
28
  - 为已有 deck 打开可视化评论编辑器,用户可以 Ctrl/Cmd + 点击元素,并把精确修改意见发回 OpenCode
27
29
  - 支持导出成 PDF 和可编辑 PPTX
@@ -177,6 +179,22 @@ Create a 6-slide HTML deck on humanoid robotics supply chains. Cite the main mar
177
179
  持久化配置保存在 `~/.config/revela/config.json`。
178
180
  是否启用 Revela 则只在当前会话生效。
179
181
 
182
+ ### Workspace State
183
+
184
+ `DECKS.json` 是 Revela 当前的 workspace state engine,也是兼容旧工作流的状态文件。它仍然保存在工作区根目录,也仍然可以理解为当前 deck 项目的状态入口,但 Revela 内部已经不再把它当成单纯的 deck checklist。
185
+
186
+ 状态中会记录:
187
+
188
+ - 工作区来源材料和可复用的 extraction cache 路径
189
+ - 调研计划、已保存 findings,以及精简的 action provenance
190
+ - narrative intent、objections、risks、slide specs、claim candidates 和 slide-level evidence trace
191
+ - active HTML deck 以及派生 PDF、PPTX 等 render targets
192
+ - 带 input hash 的 review snapshots,使重要状态变化后旧的 readiness 自动变 stale
193
+
194
+ 0.11 继续兼容已有的根目录 `DECKS.json` 工作区。对旧项目运行 `/revela init` 或 `/revela review` 时,可以安全 normalize 并刷新新的 projection 字段;用户不需要手动迁移、不需要移动文件,也不需要把 `DECKS.json` 换成数据库。
195
+
196
+ Deck 仍然是主要 authored artifact,但现在它是从同一份 workspace state 渲染出来的目标之一。后续 briefs、appendix material、Evidence Inspector views、Q&A 和 interactive reading layers 都可以复用同一套来源/证据逻辑,而不是各自生成孤立内容。
197
+
180
198
  ---
181
199
 
182
200
  ## 推荐使用流程
@@ -195,7 +213,7 @@ Create a 6-slide HTML deck on humanoid robotics supply chains. Cite the main mar
195
213
 
196
214
  `/revela review` 检查的是实际生产问题:受众是否清楚、是否缺来源材料、调研是否完成、关键 claim 是否有证据、source trace 是否太弱、页面结构是否完整、design/layout 信息是否齐全,以及轻量叙事问题,例如 so-what 不清晰、缺少风险/假设处理或转场突兀。它不会写最终 deck。
197
215
 
198
- 如果 Revela 阻止写入 deck,直接让 agent 运行 `/revela review`,根据报告补齐缺口后再写。这样可以避免在计划、证据或结构还不完整时覆盖真实 deck 文件。
216
+ 如果 Revela 阻止写入 deck,直接让 agent 运行 `/revela review`,根据报告补齐缺口后再写。这样可以避免在计划、证据或结构还不完整时覆盖真实 deck 文件。0.11 还会把 review 结果保存为带 input hash 的 snapshot;如果之后来源材料、slide specs、证据、叙事状态或 active render target 发生变化,旧 review 不能继续默默授权写入。
199
217
 
200
218
  记住长期偏好请使用:
201
219
 
@@ -562,6 +580,8 @@ Inspector 会在浏览器中打开,左侧是 deck,右侧是固定卡片:So
562
580
 
563
581
  Inspector 不是聊天,也没有自由输入框。它不会修改 `DECKS.json` 或 deck HTML。它使用已记录的 slide spec、narrative state 和 slide-level evidence trace 作为 grounded context。确定性预处理会立即显示;LLM judgment 随后 lazy 更新 Source 和 Purpose 卡片,不会强行生成编辑动作。
564
582
 
583
+ Inspect 和 refine 会使用 workspace state 中记录的 active HTML deck render target。Deck HTML 必须满足 Revela 的 slide identity contract:active artifact 中每个 `<section class="slide">` 都需要有正数、1-based 的 `data-slide-index`,并且要匹配当前 slide specs。无效的 active artifact 会在 inspect/refine/export 工作流信任它之前被拒绝或报告。
584
+
565
585
  ---
566
586
 
567
587
  ## 导出
@@ -11,7 +11,7 @@ export async function handleInspect(
11
11
  workspaceRoot: options.workspaceRoot,
12
12
  })
13
13
  await send(
14
- `Opened Evidence Inspector for the only deck in \`decks/\`.\n` +
14
+ `Opened Evidence Inspector for the active HTML deck.\n` +
15
15
  `File: \`${result.deck.file}\`\n` +
16
16
  `${result.stateNote}\n` +
17
17
  `URL: ${result.url}\n\n` +
@@ -8,28 +8,41 @@
8
8
  */
9
9
 
10
10
  import { resolve } from "path"
11
+ import { hasDecksState, readDecksState } from "../decks-state"
11
12
  import { exportToPdf } from "../pdf/export"
12
13
  import { assertExportQAPassed } from "../qa/export-gate"
14
+ import { recordRenderedArtifact, workspaceRelative } from "../workspace-state/rendered-artifacts"
15
+ import { resolveActiveHtmlDeckPath } from "../workspace-state/render-targets"
13
16
 
14
17
  export async function handlePdf(
15
18
  filePath: string,
16
19
  send: (text: string) => Promise<void>,
20
+ workspaceRoot = process.cwd(),
17
21
  ): Promise<void> {
18
- if (!filePath) {
22
+ const root = resolve(workspaceRoot)
23
+ const resolvedFile = resolvePdfDeckFile(root, filePath)
24
+
25
+ if (!resolvedFile) {
19
26
  await send(
20
- "**Usage:** `/revela pdf <file_path>`\n\n" +
27
+ "**Usage:** `/revela pdf [file_path]`\n\n" +
21
28
  "Example: `/revela pdf decks/my-deck.html`"
22
29
  )
23
30
  return
24
31
  }
25
32
 
26
- const abs = resolve(filePath)
33
+ const abs = resolvedFile.absoluteFile
27
34
  await send(`Running pre-export QA for \`${abs}\`...`)
28
35
 
29
36
  try {
30
- await assertExportQAPassed(abs)
37
+ await assertExportQAPassed(abs, { workspaceRoot: root })
31
38
  await send(`Exporting \`${abs}\` to PDF...`)
32
- const result = await exportToPdf(filePath)
39
+ const result = await exportToPdf(abs)
40
+ recordRenderedArtifact(root, {
41
+ sourceHtmlPath: resolvedFile.file,
42
+ outputPath: result.outputPath,
43
+ type: "pdf",
44
+ actor: "revela-pdf",
45
+ })
33
46
  const secs = (result.durationMs / 1000).toFixed(1)
34
47
  await send(
35
48
  `**PDF exported successfully**\n\n` +
@@ -42,3 +55,18 @@ export async function handlePdf(
42
55
  await send(`**PDF export failed**\n\n\`\`\`\n${msg}\n\`\`\``)
43
56
  }
44
57
  }
58
+
59
+ function resolvePdfDeckFile(workspaceRoot: string, filePath: string): { file: string; absoluteFile: string } | undefined {
60
+ const explicit = filePath.trim()
61
+ if (explicit) {
62
+ const absoluteFile = resolve(workspaceRoot, explicit)
63
+ return { file: workspaceRelative(workspaceRoot, absoluteFile), absoluteFile }
64
+ }
65
+
66
+ if (!hasDecksState(workspaceRoot)) return undefined
67
+ const state = readDecksState(workspaceRoot)
68
+ const activePath = resolveActiveHtmlDeckPath(state)
69
+ if (!activePath) return undefined
70
+ const absoluteFile = resolve(workspaceRoot, activePath)
71
+ return { file: workspaceRelative(workspaceRoot, absoluteFile), absoluteFile }
72
+ }
@@ -10,7 +10,10 @@
10
10
  import { existsSync, readdirSync } from "fs"
11
11
  import { relative, resolve, sep } from "path"
12
12
  import { hasDecksState, isDeckHtmlPath, readDecksState } from "../decks-state"
13
+ import { assertDeckHtmlContractValid } from "../deck-html/contract"
13
14
  import { exportToPptx } from "../pptx/export"
15
+ import { recordRenderedArtifact } from "../workspace-state/rendered-artifacts"
16
+ import { resolveActiveHtmlDeckPath } from "../workspace-state/render-targets"
14
17
 
15
18
  export interface PptxArgs {
16
19
  filePath: string
@@ -20,7 +23,7 @@ export interface PptxArgs {
20
23
  export interface ResolvedPptxDeck {
21
24
  file: string
22
25
  absoluteFile: string
23
- source: "decks-state" | "fallback" | "file-path"
26
+ source: "render-target" | "decks-state" | "fallback" | "file-path"
24
27
  }
25
28
 
26
29
  function formatSecs(ms: number): string {
@@ -46,12 +49,12 @@ export function resolvePptxDeck(workspaceRoot: string, filePath = ""): ResolvedP
46
49
 
47
50
  if (hasDecksState(root)) {
48
51
  const state = readDecksState(root)
49
- const key = state.activeDeck || singleDeckKey(state.decks)
50
- const outputPath = key ? state.decks[key]?.outputPath : undefined
52
+ const outputPath = resolveActiveHtmlDeckPath(state)
51
53
  if (outputPath && isDeckHtmlPath(outputPath)) {
52
54
  const absoluteFile = resolve(root, outputPath)
53
55
  if (existsSync(absoluteFile)) {
54
- return { file: workspaceRelative(root, absoluteFile), absoluteFile, source: "decks-state" }
56
+ const source = state.renderTargets.some((target) => target.type === "html_deck" && target.outputPath === outputPath) ? "render-target" : "decks-state"
57
+ return { file: workspaceRelative(root, absoluteFile), absoluteFile, source }
55
58
  }
56
59
  }
57
60
  }
@@ -114,6 +117,7 @@ export async function handlePptx(
114
117
  const deck = resolvePptxDeck(workspaceRoot, args.filePath)
115
118
  const abs = deck.absoluteFile
116
119
 
120
+ assertDeckHtmlContractValid(workspaceRoot, abs)
117
121
  await send(`Exporting \`${abs}\` to PPTX...`)
118
122
  let lastSlideUpdate = 0
119
123
  let longDeckThreshold: number | null = null
@@ -141,6 +145,12 @@ export async function handlePptx(
141
145
  await send(`Editable export progress: slide ${current}/${total}`)
142
146
  },
143
147
  })
148
+ recordRenderedArtifact(workspaceRoot, {
149
+ sourceHtmlPath: deck.file,
150
+ outputPath: result.outputPath,
151
+ type: "pptx",
152
+ actor: "revela-pptx",
153
+ })
144
154
 
145
155
  await send(
146
156
  `**PPTX exported successfully**\n\n` +
@@ -159,11 +169,6 @@ export async function handlePptx(
159
169
  }
160
170
  }
161
171
 
162
- function singleDeckKey(decks: Record<string, unknown>): string | undefined {
163
- const keys = Object.keys(decks)
164
- return keys.length === 1 ? keys[0] : undefined
165
- }
166
-
167
172
  function listDeckHtmlFiles(workspaceRoot: string): string[] {
168
173
  const dir = resolve(workspaceRoot, "decks")
169
174
  if (!existsSync(dir)) return []
@@ -14,7 +14,7 @@ export async function handleRefine(
14
14
  })
15
15
 
16
16
  await send(
17
- `Opened Revela Refine for the only deck in \`decks/\`.\n` +
17
+ `Opened Revela Refine for the active HTML deck.\n` +
18
18
  `File: \`${result.deck.file}\`\n` +
19
19
  `${result.stateNote}\n` +
20
20
  `URL: ${result.url}\n\n` +
@@ -0,0 +1,252 @@
1
+ import { existsSync, readFileSync } from "fs"
2
+ import { relative, resolve, sep } from "path"
3
+ import { hasDecksState, readDecksState, type DeckSpec } from "../decks-state"
4
+ import { resolveActiveHtmlDeckPath, normalizeWorkspacePath } from "../workspace-state/render-targets"
5
+
6
+ export type DeckHtmlContractStatus = "valid" | "invalid" | "skipped"
7
+
8
+ export type DeckHtmlContractIssueType =
9
+ | "file_not_found"
10
+ | "no_matching_deck_spec"
11
+ | "missing_slide_section"
12
+ | "slide_count_mismatch"
13
+ | "missing_data_slide_index"
14
+ | "invalid_data_slide_index"
15
+ | "duplicate_data_slide_index"
16
+ | "slide_index_mismatch"
17
+ | "legacy_data_index_noncanonical"
18
+
19
+ export interface DeckHtmlContractIssue {
20
+ type: DeckHtmlContractIssueType
21
+ severity: "error" | "warning"
22
+ message: string
23
+ slidePosition?: number
24
+ expectedIndex?: number
25
+ actualIndex?: number
26
+ }
27
+
28
+ export interface DeckHtmlContractReport {
29
+ status: DeckHtmlContractStatus
30
+ ok: boolean
31
+ workspaceRoot: string
32
+ filePath: string
33
+ deckSlug?: string
34
+ activeHtmlPath?: string
35
+ expectedIndexes: number[]
36
+ actualIndexes: number[]
37
+ issues: DeckHtmlContractIssue[]
38
+ warnings: DeckHtmlContractIssue[]
39
+ }
40
+
41
+ interface SlideSectionAttrs {
42
+ position: number
43
+ dataSlideIndex?: string
44
+ dataIndex?: string
45
+ }
46
+
47
+ export function validateDeckHtmlContract(workspaceRoot: string, filePath: string): DeckHtmlContractReport {
48
+ const root = resolve(workspaceRoot)
49
+ const absoluteFile = resolve(root, filePath)
50
+ const relativeFile = workspaceRelative(root, absoluteFile)
51
+ const base: Omit<DeckHtmlContractReport, "status" | "ok"> = {
52
+ workspaceRoot: root,
53
+ filePath: relativeFile,
54
+ expectedIndexes: [],
55
+ actualIndexes: [],
56
+ issues: [],
57
+ warnings: [],
58
+ }
59
+
60
+ if (!hasDecksState(root)) return skipped(base, "No DECKS.json exists; deck HTML contract validation is skipped.")
61
+
62
+ const state = readDecksState(root)
63
+ const activeHtmlPath = resolveActiveHtmlDeckPath(state)
64
+ const activeKey = state.activeDeck || singleDeckKey(state.decks)
65
+ const deck = activeKey ? state.decks[activeKey] : undefined
66
+ const normalizedActive = normalizeWorkspacePath(activeHtmlPath ?? "")
67
+ const normalizedTarget = normalizeWorkspacePath(relativeFile)
68
+
69
+ base.activeHtmlPath = normalizedActive || undefined
70
+ base.deckSlug = deck?.slug
71
+
72
+ if (!deck || !normalizedActive || normalizedActive !== normalizedTarget) {
73
+ return skipped(base, `No matching active deck spec exists for ${relativeFile}; deck HTML contract validation is skipped.`)
74
+ }
75
+
76
+ if (!existsSync(absoluteFile)) {
77
+ base.issues.push({
78
+ type: "file_not_found",
79
+ severity: "error",
80
+ message: `Deck HTML file not found: ${relativeFile}`,
81
+ })
82
+ return finalize(base)
83
+ }
84
+
85
+ const html = readFileSync(absoluteFile, "utf-8")
86
+ const sections = extractSlideSections(html)
87
+ base.expectedIndexes = expectedSlideIndexes(deck)
88
+
89
+ if (sections.length === 0) {
90
+ base.issues.push({
91
+ type: "missing_slide_section",
92
+ severity: "error",
93
+ message: "Deck HTML must contain one <section class=\"slide\"> element per slide spec.",
94
+ })
95
+ return finalize(base)
96
+ }
97
+
98
+ if (sections.length !== base.expectedIndexes.length) {
99
+ base.issues.push({
100
+ type: "slide_count_mismatch",
101
+ severity: "error",
102
+ message: `Deck HTML has ${sections.length} slide sections, but DECKS.json expects ${base.expectedIndexes.length}.`,
103
+ })
104
+ }
105
+
106
+ const seen = new Set<number>()
107
+ sections.forEach((section, offset) => {
108
+ const expectedIndex = base.expectedIndexes[offset]
109
+ if (section.dataIndex !== undefined) {
110
+ base.warnings.push({
111
+ type: "legacy_data_index_noncanonical",
112
+ severity: "warning",
113
+ message: `Slide ${section.position} has legacy data-index; use data-slide-index as the canonical slide identity.`,
114
+ slidePosition: section.position,
115
+ })
116
+ }
117
+
118
+ if (section.dataSlideIndex === undefined) {
119
+ base.issues.push({
120
+ type: "missing_data_slide_index",
121
+ severity: "error",
122
+ message: `Slide ${section.position} is missing data-slide-index.`,
123
+ slidePosition: section.position,
124
+ expectedIndex,
125
+ })
126
+ return
127
+ }
128
+
129
+ const actualIndex = Number(section.dataSlideIndex)
130
+ if (!Number.isInteger(actualIndex) || actualIndex < 1 || String(actualIndex) !== section.dataSlideIndex.trim()) {
131
+ base.issues.push({
132
+ type: "invalid_data_slide_index",
133
+ severity: "error",
134
+ message: `Slide ${section.position} has invalid data-slide-index=${JSON.stringify(section.dataSlideIndex)}; expected a positive 1-based integer.`,
135
+ slidePosition: section.position,
136
+ expectedIndex,
137
+ })
138
+ return
139
+ }
140
+
141
+ base.actualIndexes.push(actualIndex)
142
+ if (seen.has(actualIndex)) {
143
+ base.issues.push({
144
+ type: "duplicate_data_slide_index",
145
+ severity: "error",
146
+ message: `Slide ${section.position} repeats data-slide-index=${actualIndex}.`,
147
+ slidePosition: section.position,
148
+ actualIndex,
149
+ })
150
+ }
151
+ seen.add(actualIndex)
152
+
153
+ if (expectedIndex !== undefined && actualIndex !== expectedIndex) {
154
+ base.issues.push({
155
+ type: "slide_index_mismatch",
156
+ severity: "error",
157
+ message: `Slide ${section.position} has data-slide-index=${actualIndex}, but DECKS.json expects ${expectedIndex}.`,
158
+ slidePosition: section.position,
159
+ expectedIndex,
160
+ actualIndex,
161
+ })
162
+ }
163
+ })
164
+
165
+ return finalize(base)
166
+ }
167
+
168
+ export function assertDeckHtmlContractValid(workspaceRoot: string, filePath: string): void {
169
+ const report = validateDeckHtmlContract(workspaceRoot, filePath)
170
+ if (report.status !== "invalid") return
171
+ throw new Error(
172
+ "Deck HTML contract validation failed. Fix slide identity before inspection or export.\n\n" +
173
+ formatDeckHtmlContractReport(report)
174
+ )
175
+ }
176
+
177
+ export function formatDeckHtmlContractReport(report: DeckHtmlContractReport): string {
178
+ const lines = [
179
+ `Status: ${report.status}`,
180
+ `File: ${report.filePath}`,
181
+ ]
182
+ if (report.deckSlug) lines.push(`Deck: ${report.deckSlug}`)
183
+ if (report.activeHtmlPath) lines.push(`Active HTML target: ${report.activeHtmlPath}`)
184
+ if (report.expectedIndexes.length > 0) lines.push(`Expected slide indexes: ${report.expectedIndexes.join(", ")}`)
185
+ if (report.actualIndexes.length > 0) lines.push(`Actual slide indexes: ${report.actualIndexes.join(", ")}`)
186
+
187
+ if (report.issues.length > 0) {
188
+ lines.push("", "Errors:")
189
+ for (const issue of report.issues) lines.push(`- ${issue.message}`)
190
+ }
191
+ if (report.warnings.length > 0) {
192
+ lines.push("", "Warnings:")
193
+ for (const warning of report.warnings) lines.push(`- ${warning.message}`)
194
+ }
195
+ if (report.status === "skipped" && report.warnings.length > 0) {
196
+ lines.push("", "Note: skipped reports do not block standalone HTML files.")
197
+ }
198
+
199
+ return lines.join("\n")
200
+ }
201
+
202
+ function extractSlideSections(html: string): SlideSectionAttrs[] {
203
+ const sections: SlideSectionAttrs[] = []
204
+ const sectionTagPattern = /<section\b([^>]*)>/gi
205
+ let match: RegExpExecArray | null
206
+ while ((match = sectionTagPattern.exec(html))) {
207
+ const attrs = match[1] ?? ""
208
+ if (!/\bclass\s*=\s*(["'])[^"']*\bslide\b[^"']*\1/i.test(attrs)) continue
209
+ sections.push({
210
+ position: sections.length + 1,
211
+ dataSlideIndex: readAttr(attrs, "data-slide-index"),
212
+ dataIndex: readAttr(attrs, "data-index"),
213
+ })
214
+ }
215
+ return sections
216
+ }
217
+
218
+ function readAttr(attrs: string, name: string): string | undefined {
219
+ const pattern = new RegExp(`\\b${escapeRegExp(name)}\\s*=\\s*(["'])(.*?)\\1`, "i")
220
+ return pattern.exec(attrs)?.[2]
221
+ }
222
+
223
+ function expectedSlideIndexes(deck: DeckSpec): number[] {
224
+ return deck.slides.map((slide) => slide.index)
225
+ }
226
+
227
+ function finalize(base: Omit<DeckHtmlContractReport, "status" | "ok">): DeckHtmlContractReport {
228
+ const status: DeckHtmlContractStatus = base.issues.length > 0 ? "invalid" : "valid"
229
+ return { ...base, status, ok: status === "valid" }
230
+ }
231
+
232
+ function skipped(base: Omit<DeckHtmlContractReport, "status" | "ok">, message: string): DeckHtmlContractReport {
233
+ return {
234
+ ...base,
235
+ status: "skipped",
236
+ ok: true,
237
+ warnings: [{ type: "no_matching_deck_spec", severity: "warning", message }],
238
+ }
239
+ }
240
+
241
+ function singleDeckKey(decks: Record<string, DeckSpec>): string | undefined {
242
+ const keys = Object.keys(decks)
243
+ return keys.length === 1 ? keys[0] : undefined
244
+ }
245
+
246
+ function workspaceRelative(root: string, target: string): string {
247
+ return relative(root, target).split(sep).join("/")
248
+ }
249
+
250
+ function escapeRegExp(value: string): string {
251
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
252
+ }