@cyber-dash-tech/revela 0.9.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.
Files changed (48) hide show
  1. package/README.md +54 -9
  2. package/README.zh-CN.md +54 -9
  3. package/designs/monet/DESIGN.md +9 -9
  4. package/designs/starter/DESIGN.md +8 -8
  5. package/designs/summit/DESIGN.md +9 -9
  6. package/lib/commands/help.ts +2 -0
  7. package/lib/commands/inspect.ts +23 -0
  8. package/lib/commands/pdf.ts +33 -5
  9. package/lib/commands/pptx.ts +14 -9
  10. package/lib/commands/refine.ts +26 -0
  11. package/lib/commands/review.ts +8 -2
  12. package/lib/deck-html/contract.ts +252 -0
  13. package/lib/decks-state.ts +574 -31
  14. package/lib/document-materials/extract.ts +20 -0
  15. package/lib/edit/resolve-deck.ts +13 -2
  16. package/lib/inspect/open.ts +63 -0
  17. package/lib/inspect/prompt.ts +32 -0
  18. package/lib/inspect/request.ts +70 -0
  19. package/lib/inspect/requests.ts +86 -0
  20. package/lib/inspect/server.ts +1063 -0
  21. package/lib/inspect/slide-index.ts +12 -0
  22. package/lib/inspection-context/compile.ts +346 -0
  23. package/lib/inspection-context/match.ts +169 -0
  24. package/lib/inspection-context/project.ts +263 -0
  25. package/lib/inspection-context/result.ts +160 -0
  26. package/lib/qa/export-gate.ts +8 -1
  27. package/lib/refine/open.ts +70 -0
  28. package/lib/refine/server.ts +1581 -0
  29. package/lib/workspace-state/actions.ts +71 -0
  30. package/lib/workspace-state/compat.ts +10 -0
  31. package/lib/workspace-state/evidence-status.ts +267 -0
  32. package/lib/workspace-state/graph.ts +426 -0
  33. package/lib/workspace-state/render-targets.ts +182 -0
  34. package/lib/workspace-state/rendered-artifacts.ts +43 -0
  35. package/lib/workspace-state/repository.ts +43 -0
  36. package/lib/workspace-state/research-attachments.ts +130 -0
  37. package/lib/workspace-state/review-snapshots.ts +127 -0
  38. package/lib/workspace-state/types.ts +119 -0
  39. package/package.json +1 -1
  40. package/plugin.ts +48 -1
  41. package/skill/SKILL.md +10 -5
  42. package/tools/decks.ts +61 -2
  43. package/tools/inspection-context.ts +22 -0
  44. package/tools/inspection-result.ts +63 -0
  45. package/tools/pdf.ts +9 -1
  46. package/tools/pptx.ts +10 -0
  47. package/tools/research-save.ts +15 -0
  48. package/tools/workspace-scan.ts +15 -0
@@ -0,0 +1,130 @@
1
+ import { existsSync } from "fs"
2
+ import { basename, resolve, sep } from "path"
3
+ import {
4
+ readDecksState,
5
+ writeDecksState,
6
+ type DecksState,
7
+ type ResearchAxis,
8
+ } from "../decks-state"
9
+ import { recordWorkspaceAction } from "./actions"
10
+
11
+ export interface AttachResearchFindingsInput {
12
+ findingsFile: string
13
+ researchAxis?: string
14
+ status?: "done" | "read"
15
+ }
16
+
17
+ export interface AttachResearchFindingsResult {
18
+ attached: boolean
19
+ skipped: boolean
20
+ reason?: string
21
+ slug?: string
22
+ axis?: string
23
+ findingsFile?: string
24
+ status?: ResearchAxis["status"]
25
+ }
26
+
27
+ export function attachResearchFindings(workspaceRoot: string, input: AttachResearchFindingsInput): AttachResearchFindingsResult {
28
+ const state = readDecksState(workspaceRoot)
29
+ const result = attachResearchFindingsToState(state, workspaceRoot, input)
30
+ writeDecksState(workspaceRoot, state)
31
+ return result
32
+ }
33
+
34
+ export function attachResearchFindingsToState(state: DecksState, workspaceRoot: string, input: AttachResearchFindingsInput): AttachResearchFindingsResult {
35
+ const normalizedFile = normalizeResearchFindingsPath(input.findingsFile)
36
+ if (!normalizedFile) {
37
+ return recordSkipped(state, input, "findingsFile must be a workspace-relative researches/*.md path")
38
+ }
39
+
40
+ const absoluteFile = safeWorkspacePath(workspaceRoot, normalizedFile)
41
+ if (!absoluteFile || !existsSync(absoluteFile)) {
42
+ return recordSkipped(state, { ...input, findingsFile: normalizedFile }, "findingsFile does not exist inside the workspace")
43
+ }
44
+
45
+ const slug = state.activeDeck ?? singleDeckKey(state)
46
+ const deck = slug ? state.decks[slug] : undefined
47
+ if (!slug || !deck) return recordSkipped(state, { ...input, findingsFile: normalizedFile }, "no active deck is available")
48
+
49
+ const matches = matchingAxes(deck.researchPlan ?? [], input.researchAxis, normalizedFile)
50
+ if (matches.length === 0) return recordSkipped(state, { ...input, findingsFile: normalizedFile }, "no matching researchPlan axis found")
51
+ if (matches.length > 1) return recordSkipped(state, { ...input, findingsFile: normalizedFile }, "researchPlan axis match is ambiguous")
52
+
53
+ const index = matches[0]!
54
+ const existing = deck.researchPlan[index]!
55
+ const nextStatus = input.status ?? existing.status
56
+ deck.researchPlan[index] = {
57
+ ...existing,
58
+ status: nextStatus,
59
+ findingsFile: normalizedFile,
60
+ }
61
+
62
+ recordWorkspaceAction(state, {
63
+ type: "research.findings_attached",
64
+ actor: "revela-decks",
65
+ inputs: { activeDeck: slug, axis: existing.axis, findingsFile: normalizedFile, requestedStatus: input.status },
66
+ outputs: { slug, axis: existing.axis, findingsFile: normalizedFile, status: nextStatus },
67
+ summary: `Attached research findings ${normalizedFile} to axis ${existing.axis}.`,
68
+ nodeIds: [`finding:${normalizedFile}`],
69
+ })
70
+
71
+ return {
72
+ attached: true,
73
+ skipped: false,
74
+ slug,
75
+ axis: existing.axis,
76
+ findingsFile: normalizedFile,
77
+ status: nextStatus,
78
+ }
79
+ }
80
+
81
+ function matchingAxes(researchPlan: ResearchAxis[], researchAxis: string | undefined, findingsFile: string): number[] {
82
+ if (researchAxis?.trim()) {
83
+ const requested = normalizeKey(researchAxis)
84
+ return researchPlan.flatMap((axis, index) => normalizeKey(axis.axis) === requested ? [index] : [])
85
+ }
86
+
87
+ const fileKey = normalizeKey(basename(findingsFile, ".md"))
88
+ return researchPlan.flatMap((axis, index) => normalizeKey(axis.axis) === fileKey ? [index] : [])
89
+ }
90
+
91
+ function recordSkipped(state: DecksState, input: AttachResearchFindingsInput, reason: string): AttachResearchFindingsResult {
92
+ const normalizedFile = normalizeResearchFindingsPath(input.findingsFile) ?? input.findingsFile
93
+ recordWorkspaceAction(state, {
94
+ type: "research.findings_attached",
95
+ actor: "revela-decks",
96
+ inputs: { axis: input.researchAxis, findingsFile: normalizedFile, requestedStatus: input.status },
97
+ outputs: { reason },
98
+ status: "skipped",
99
+ summary: `Skipped research findings attachment: ${reason}.`,
100
+ nodeIds: normalizedFile ? [`finding:${normalizedFile}`] : [],
101
+ })
102
+ return { attached: false, skipped: true, reason }
103
+ }
104
+
105
+ function normalizeResearchFindingsPath(filePath: string | undefined): string | undefined {
106
+ const normalized = normalizePath(filePath ?? "").replace(/^\.\//, "")
107
+ if (!normalized || normalized.startsWith("../") || normalized.startsWith("/")) return undefined
108
+ if (!normalized.startsWith("researches/") || !normalized.endsWith(".md")) return undefined
109
+ return normalized
110
+ }
111
+
112
+ function safeWorkspacePath(workspaceRoot: string, relativePath: string): string | undefined {
113
+ const root = resolve(workspaceRoot)
114
+ const target = resolve(root, relativePath)
115
+ if (target !== root && !target.startsWith(root + sep)) return undefined
116
+ return target
117
+ }
118
+
119
+ function singleDeckKey(state: DecksState): string | undefined {
120
+ const keys = Object.keys(state.decks)
121
+ return keys.length === 1 ? keys[0] : undefined
122
+ }
123
+
124
+ function normalizeKey(value: string): string {
125
+ return value.toLowerCase().replace(/[^a-z0-9\u4e00-\u9fa5]+/g, "-").replace(/^-+|-+$/g, "")
126
+ }
127
+
128
+ function normalizePath(filePath: string): string {
129
+ return filePath.replace(/\\/g, "/")
130
+ }
@@ -0,0 +1,127 @@
1
+ import { createHash } from "crypto"
2
+ import type { DeckSpec, DecksState, DeckStateReadinessResult } from "../decks-state"
3
+ import { projectWorkspaceGraph } from "./graph"
4
+ import { activeHtmlDeckRenderTarget, ensureActiveHtmlDeckRenderTarget } from "./render-targets"
5
+ import type { ReviewSnapshot } from "./types"
6
+
7
+ export const MAX_REVIEW_SNAPSHOTS = 50
8
+
9
+ export interface ReviewSnapshotInput {
10
+ slug: string
11
+ result: DeckStateReadinessResult
12
+ reviewedAt?: string
13
+ }
14
+
15
+ export function currentReviewInputHash(state: DecksState, slug?: string): string {
16
+ return stableHash(stableStringify(reviewInputProjection(state, slug)))
17
+ }
18
+
19
+ export function activeReviewTargetId(state: DecksState): string | undefined {
20
+ return activeHtmlDeckRenderTarget(state)?.id ?? ensureActiveHtmlDeckRenderTarget(state)?.id
21
+ }
22
+
23
+ export function createReviewSnapshot(state: DecksState, input: ReviewSnapshotInput): ReviewSnapshot {
24
+ const reviewedAt = input.reviewedAt ?? new Date().toISOString()
25
+ const targetId = activeReviewTargetId(state)
26
+ const inputHash = currentReviewInputHash(state, input.slug)
27
+ return {
28
+ id: reviewSnapshotId(targetId, inputHash, reviewedAt),
29
+ ...(targetId ? { targetId } : {}),
30
+ inputHash,
31
+ status: input.result.status ?? (input.result.ready ? "ready" : "blocked"),
32
+ blockers: input.result.blockers,
33
+ warnings: input.result.warnings,
34
+ issues: input.result.issues,
35
+ ...(input.result.evidenceCandidates ? { evidenceCandidates: input.result.evidenceCandidates } : {}),
36
+ reviewedAt,
37
+ }
38
+ }
39
+
40
+ export function appendReviewSnapshot(state: DecksState, snapshot: ReviewSnapshot): DecksState {
41
+ const next = (state.reviews ?? []).filter((item) => item.id !== snapshot.id)
42
+ next.push(snapshot)
43
+ state.reviews = next
44
+ .sort((a, b) => a.reviewedAt.localeCompare(b.reviewedAt))
45
+ .slice(-MAX_REVIEW_SNAPSHOTS)
46
+ return state
47
+ }
48
+
49
+ export function latestReviewSnapshotForTarget(state: DecksState, targetId?: string): ReviewSnapshot | undefined {
50
+ const reviews = state.reviews ?? []
51
+ const candidates = targetId ? reviews.filter((item) => item.targetId === targetId) : reviews
52
+ return candidates.reduce<ReviewSnapshot | undefined>((latest, item) => {
53
+ if (!latest) return item
54
+ return item.reviewedAt.localeCompare(latest.reviewedAt) >= 0 ? item : latest
55
+ }, undefined)
56
+ }
57
+
58
+ export function isReviewSnapshotCurrent(state: DecksState, snapshot: ReviewSnapshot, slug?: string): boolean {
59
+ return snapshot.inputHash === currentReviewInputHash(state, slug)
60
+ }
61
+
62
+ function reviewInputProjection(state: DecksState, slug?: string): unknown {
63
+ const key = slug || state.activeDeck || singleDeckKey(state.decks)
64
+ const deck = key ? state.decks[key] : undefined
65
+ const stableState = cloneForGraphProjection(state, key)
66
+ return {
67
+ version: state.version,
68
+ activeDeck: key,
69
+ workspace: {
70
+ brief: state.workspace.brief,
71
+ sourceMaterials: state.workspace.sourceMaterials,
72
+ openQuestions: state.workspace.openQuestions,
73
+ },
74
+ deck: deck ? stableDeckProjection(deck) : undefined,
75
+ renderTarget: activeHtmlDeckRenderTarget(state) ?? ensureActiveHtmlDeckRenderTarget(state),
76
+ graph: deck ? projectWorkspaceGraph(stableState, { slug: key }) : undefined,
77
+ }
78
+ }
79
+
80
+ function stableDeckProjection(deck: DeckSpec): unknown {
81
+ return {
82
+ slug: deck.slug,
83
+ goal: deck.goal,
84
+ audience: deck.audience,
85
+ language: deck.language,
86
+ outputPath: deck.outputPath,
87
+ narrativeBrief: deck.narrativeBrief,
88
+ theme: deck.theme,
89
+ requiredInputs: deck.requiredInputs,
90
+ researchPlan: deck.researchPlan,
91
+ slides: deck.slides,
92
+ assets: deck.assets,
93
+ }
94
+ }
95
+
96
+ function cloneForGraphProjection(state: DecksState, slug?: string): DecksState {
97
+ const clone = structuredClone(state) as DecksState
98
+ const key = slug || clone.activeDeck || singleDeckKey(clone.decks)
99
+ const deck = key ? clone.decks[key] : undefined
100
+ if (deck) {
101
+ deck.status = "planning"
102
+ deck.writeReadiness = { status: "blocked", blockers: [] }
103
+ }
104
+ clone.actions = []
105
+ clone.reviews = []
106
+ return clone
107
+ }
108
+
109
+ function reviewSnapshotId(targetId: string | undefined, inputHash: string, reviewedAt: string): string {
110
+ return `review:${targetId ?? "workspace"}:${inputHash.slice(0, 12)}:${stableHash(reviewedAt).slice(0, 8)}`
111
+ }
112
+
113
+ function stableHash(value: string): string {
114
+ return createHash("sha1").update(value).digest("hex")
115
+ }
116
+
117
+ function stableStringify(value: unknown): string {
118
+ if (value === null || typeof value !== "object") return JSON.stringify(value)
119
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
120
+ const object = value as Record<string, unknown>
121
+ return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(object[key])}`).join(",")}}`
122
+ }
123
+
124
+ function singleDeckKey(decks: Record<string, DeckSpec>): string | undefined {
125
+ const keys = Object.keys(decks)
126
+ return keys.length === 1 ? keys[0] : undefined
127
+ }
@@ -0,0 +1,119 @@
1
+ import type { DecksState } from "../decks-state"
2
+
3
+ export const WORKSPACE_STATE_FILE = "DECKS.json"
4
+
5
+ export type WorkspaceStateVersion = 1 | 2
6
+
7
+ export interface WorkspaceStateRepositoryOptions<TState> {
8
+ fileName?: string
9
+ normalize?: (state: TState) => TState
10
+ }
11
+
12
+ export interface WorkspaceStateV2 {
13
+ version: 2
14
+ workspace: WorkspaceMeta
15
+ graph: WorkspaceGraph
16
+ actions: WorkspaceAction[]
17
+ renderTargets: RenderTarget[]
18
+ reviews: ReviewSnapshot[]
19
+ compatibility?: DecksStateV1Projection
20
+ }
21
+
22
+ export interface WorkspaceMeta {
23
+ brief?: string
24
+ preferences?: {
25
+ user: string[]
26
+ workflow: string[]
27
+ }
28
+ openQuestions?: string[]
29
+ }
30
+
31
+ export interface WorkspaceGraph {
32
+ nodes: Record<string, GraphNode>
33
+ edges: GraphEdge[]
34
+ }
35
+
36
+ export interface GraphNode {
37
+ id: string
38
+ type: GraphNodeType
39
+ label?: string
40
+ data?: Record<string, unknown>
41
+ }
42
+
43
+ export type GraphNodeType =
44
+ | "source"
45
+ | "extraction"
46
+ | "finding"
47
+ | "claim"
48
+ | "narrativeIntent"
49
+ | "objection"
50
+ | "risk"
51
+ | "slide"
52
+ | "artifact"
53
+
54
+ export interface GraphEdge {
55
+ id: string
56
+ type: GraphEdgeType
57
+ from: string
58
+ to: string
59
+ data?: Record<string, unknown>
60
+ }
61
+
62
+ export type GraphEdgeType =
63
+ | "contains"
64
+ | "extracted_as"
65
+ | "produced"
66
+ | "supports"
67
+ | "appears_in"
68
+ | "challenges"
69
+ | "constrained_by"
70
+ | "renders_from"
71
+ | "derived_from"
72
+
73
+ export interface WorkspaceAction {
74
+ id: string
75
+ type: WorkspaceActionType
76
+ timestamp: string
77
+ actor?: string
78
+ inputs?: Record<string, unknown>
79
+ outputs?: Record<string, unknown>
80
+ status: "success" | "failed" | "skipped"
81
+ summary?: string
82
+ nodeIds?: string[]
83
+ }
84
+
85
+ export type WorkspaceActionType =
86
+ | "workspace.scanned"
87
+ | "source.discovered"
88
+ | "source.extracted"
89
+ | "research.findings_saved"
90
+ | "research.findings_attached"
91
+ | "evidence.candidate_generated"
92
+ | "evidence.binding_applied"
93
+ | "review.performed"
94
+ | "artifact.rendered"
95
+
96
+ export interface RenderTarget {
97
+ id: string
98
+ type: "html_deck" | "pdf" | "pptx" | "brief" | "appendix" | "qa_view" | "interactive_page"
99
+ outputPath?: string
100
+ sourceNodeIds: string[]
101
+ artifactVersion?: string
102
+ contractStatus?: "unknown" | "valid" | "invalid" | "stale"
103
+ data?: Record<string, unknown>
104
+ }
105
+
106
+ export interface ReviewSnapshot {
107
+ id: string
108
+ targetId?: string
109
+ inputHash: string
110
+ status: "blocked" | "ready" | "written"
111
+ blockers: string[]
112
+ warnings: string[]
113
+ issues: unknown[]
114
+ evidenceCandidates?: unknown[]
115
+ reviewedAt: string
116
+ }
117
+
118
+ export type DecksStateV1Projection = DecksState
119
+ export type WorkspaceState = DecksState | WorkspaceStateV2
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.9.0",
3
+ "version": "0.11.0",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/plugin.ts CHANGED
@@ -46,6 +46,9 @@ import {
46
46
  import { handlePdf } from "./lib/commands/pdf"
47
47
  import { buildPptxNotesPrompt, handlePptx, parsePptxArgs, resolvePptxDeck } from "./lib/commands/pptx"
48
48
  import { handleEdit } from "./lib/commands/edit"
49
+ import { handleInspect } from "./lib/commands/inspect"
50
+ import { handleRefine } from "./lib/commands/refine"
51
+ import { formatDeckHtmlContractReport, validateDeckHtmlContract } from "./lib/deck-html/contract"
49
52
  import { ensureEditableDeckOpenForChange } from "./lib/edit/open"
50
53
  import { hasLiveEditorSessionForFile } from "./lib/edit/server"
51
54
  import { handleDesignsPreview } from "./lib/commands/designs-preview"
@@ -80,6 +83,8 @@ import mediaBatchSaveTool from "./tools/media-batch-save"
80
83
  import mediaSaveTool from "./tools/media-save"
81
84
  import researchImagesListTool from "./tools/research-images-list"
82
85
  import researchSaveTool from "./tools/research-save"
86
+ import inspectionContextTool from "./tools/inspection-context"
87
+ import inspectionResultTool from "./tools/inspection-result"
83
88
  import workspaceScanTool from "./tools/workspace-scan"
84
89
  import extractDocumentMaterialsTool from "./tools/extract-document-materials"
85
90
  import qaTool from "./tools/qa"
@@ -173,6 +178,27 @@ const server: Plugin = (async (pluginCtx) => {
173
178
  }
174
179
  }
175
180
 
181
+ async function appendDeckHtmlContractReport(filePath: string, output: any): Promise<void> {
182
+ if (!isDeckHtmlPath(filePath)) return
183
+
184
+ try {
185
+ const report = validateDeckHtmlContract(workspaceRoot, filePath)
186
+ if (report.status === "valid" || report.status === "skipped") return
187
+
188
+ appendToolResult(
189
+ output,
190
+ "---\n\n**[revela deck HTML contract]** Slide identity check failed:\n\n" +
191
+ formatDeckHtmlContractReport(report) +
192
+ "\n\nFix every `<section class=\"slide\">` to use the matching 1-based `data-slide-index` from DECKS.json before inspection or export."
193
+ )
194
+ } catch (e) {
195
+ childLog("deck-contract").warn("deck HTML contract report failed", {
196
+ filePath,
197
+ error: e instanceof Error ? e.message : String(e),
198
+ })
199
+ }
200
+ }
201
+
176
202
  function extractSessionID(input: any): string {
177
203
  return input?.sessionID ?? input?.session?.id ?? input?.context?.sessionID ?? ""
178
204
  }
@@ -332,6 +358,14 @@ const server: Plugin = (async (pluginCtx) => {
332
358
  } as any)
333
359
  return
334
360
  }
361
+ if (sub === "refine") {
362
+ if (param) {
363
+ await send("`/revela refine` does not accept a target. It opens the only HTML deck in `decks/`.")
364
+ throw new Error("__REVELA_REFINE_USAGE_HANDLED__")
365
+ }
366
+ await handleRefine({ client, sessionID, workspaceRoot }, send)
367
+ throw new Error("__REVELA_REFINE_HANDLED__")
368
+ }
335
369
  if (sub === "edit") {
336
370
  if (param) {
337
371
  await send("`/revela edit` no longer accepts a target. It opens the only HTML deck in `decks/`.")
@@ -340,6 +374,14 @@ const server: Plugin = (async (pluginCtx) => {
340
374
  await handleEdit({ client, sessionID, workspaceRoot }, send)
341
375
  throw new Error("__REVELA_EDIT_HANDLED__")
342
376
  }
377
+ if (sub === "inspect") {
378
+ if (param) {
379
+ await send("`/revela inspect` does not accept a target. It opens the only HTML deck in `decks/`.")
380
+ throw new Error("__REVELA_INSPECT_USAGE_HANDLED__")
381
+ }
382
+ await handleInspect({ client, sessionID, workspaceRoot }, send)
383
+ throw new Error("__REVELA_INSPECT_HANDLED__")
384
+ }
343
385
  if (sub === "designs" && !param) {
344
386
  await handleDesignsList(send)
345
387
  throw new Error("__REVELA_DESIGNS_LIST_HANDLED__")
@@ -403,7 +445,7 @@ const server: Plugin = (async (pluginCtx) => {
403
445
  throw new Error("__REVELA_DOMAINS_RM_HANDLED__")
404
446
  }
405
447
  if (sub === "pdf") {
406
- await handlePdf(param, send)
448
+ await handlePdf(param, send, workspaceRoot)
407
449
  throw new Error("__REVELA_PDF_HANDLED__")
408
450
  }
409
451
  if (sub === "pptx") {
@@ -438,6 +480,8 @@ const server: Plugin = (async (pluginCtx) => {
438
480
  "revela-media-save": mediaSaveTool,
439
481
  "revela-research-images-list": researchImagesListTool,
440
482
  "revela-research-save": researchSaveTool,
483
+ "revela-inspection-context": inspectionContextTool,
484
+ "revela-inspection-result": inspectionResultTool,
441
485
  "revela-workspace-scan": workspaceScanTool,
442
486
  "revela-extract-document-materials": extractDocumentMaterialsTool,
443
487
  "revela-qa": qaTool,
@@ -720,6 +764,7 @@ Next step: use \`revela-decks\` or \`/revela review\` to update ${DECKS_STATE_FI
720
764
  return
721
765
  }
722
766
  await appendComplianceReport(filePath, output)
767
+ await appendDeckHtmlContractReport(filePath, output)
723
768
  ensureEditorOpenAfterDeckChange(filePath, extractSessionID(input))
724
769
  return
725
770
  }
@@ -741,6 +786,7 @@ Next step: use \`revela-decks\` or \`/revela review\` to update ${DECKS_STATE_FI
741
786
  const targets = patchText ? extractDeckHtmlTargetsFromPatch(patchText) : []
742
787
  for (const target of targets) {
743
788
  await appendComplianceReport(target, output)
789
+ await appendDeckHtmlContractReport(target, output)
744
790
  ensureEditorOpenAfterDeckChange(target, extractSessionID(input))
745
791
  }
746
792
  return
@@ -749,6 +795,7 @@ Next step: use \`revela-decks\` or \`/revela review\` to update ${DECKS_STATE_FI
749
795
  if (input.tool === "edit") {
750
796
  const filePath = extractEditFilePath(input.args)
751
797
  await appendComplianceReport(filePath, output)
798
+ await appendDeckHtmlContractReport(filePath, output)
752
799
  ensureEditorOpenAfterDeckChange(filePath, extractSessionID(input))
753
800
  return
754
801
  }
package/skill/SKILL.md CHANGED
@@ -261,12 +261,17 @@ A 6-slide deck might be: Cover → Background → Content × 3 → Closing.
261
261
  An 8-slide deck might be: Cover → TOC → Background → Content × 3 → Summary → Closing.
262
262
  Never skip Cover, Background, or Closing regardless of deck length.
263
263
 
264
- **Every `<section class="slide">` must include a `slide-qa` attribute.** Set
265
- `slide-qa="true"` for content-heavy layouts (those marked ✓ in the Layout Index
266
- QA column of the active design). Set `slide-qa="false"` for structural or sparse
267
- layouts (cover, TOC, closing, quote, summary, etc.). When unsure, use `"false"`.
264
+ **Every `<section class="slide">` must include `slide-qa` and
265
+ `data-slide-index` attributes.** Set `slide-qa="true"` for content-heavy layouts
266
+ (those marked ✓ in the Layout Index QA column of the active design). Set
267
+ `slide-qa="false"` for structural or sparse layouts (cover, TOC, closing, quote,
268
+ summary, etc.). When unsure, use `"false"`.
268
269
 
269
- Example: `<section class="slide" slide-qa="true" data-index="0">`
270
+ `data-slide-index` is the canonical 1-based slide identity. It must match the
271
+ corresponding `DECKS.json` `slides[].index` value. Do not use 0-based
272
+ `data-index` as slide identity.
273
+
274
+ Example: `<section class="slide" slide-qa="true" data-slide-index="1">`
270
275
 
271
276
  The export QA path treats this as deck metadata. It is consumed when PDF/PPTX
272
277
  export runs preflight checks.
package/tools/decks.ts CHANGED
@@ -17,6 +17,10 @@ import {
17
17
  type SlideSpec,
18
18
  } from "../lib/decks-state"
19
19
  import { upsertSourceMaterial } from "../lib/source-materials"
20
+ import { recordWorkspaceAction } from "../lib/workspace-state/actions"
21
+ import { applyEvidenceBindings } from "../lib/workspace-state/evidence-status"
22
+ import { attachResearchFindings } from "../lib/workspace-state/research-attachments"
23
+ import { activeReviewTargetId, latestReviewSnapshotForTarget } from "../lib/workspace-state/review-snapshots"
20
24
 
21
25
  export default tool({
22
26
  description:
@@ -25,7 +29,7 @@ export default tool({
25
29
  "It stores active deck specs, per-slide content/layout/components, and computes write readiness.",
26
30
  args: {
27
31
  action: tool.schema
28
- .enum(["read", "init", "upsertDeck", "upsertSlides", "review", "remember"])
32
+ .enum(["read", "init", "upsertDeck", "upsertSlides", "review", "applyEvidenceCandidates", "attachResearchFindings", "remember"])
29
33
  .describe("Action to perform on DECKS.json."),
30
34
  summary: tool.schema.boolean().optional().describe("For read: return a compact summary instead of full state."),
31
35
  goal: tool.schema.string().optional().describe("For upsertDeck: deck goal."),
@@ -116,6 +120,10 @@ export default tool({
116
120
  status: tool.schema.enum(["planned", "ready", "written", "qa_passed", "qa_failed"]).optional().describe("Slide production status."),
117
121
  notes: tool.schema.string().optional().describe("Implementation notes for this slide."),
118
122
  })).optional().describe("For upsertSlides: complete or partial slide specs."),
123
+ candidateIds: tool.schema.array(tool.schema.string()).optional().describe("For applyEvidenceCandidates: candidate IDs returned by revela-decks review to explicitly bind proposed evidenceDraft records into slide evidence."),
124
+ findingsFile: tool.schema.string().optional().describe("For attachResearchFindings: workspace-relative researches/{topic}/{axis}.md file to attach to researchPlan."),
125
+ researchAxis: tool.schema.string().optional().describe("For attachResearchFindings: researchPlan axis to attach the findings file to. Required when filename matching would be ambiguous."),
126
+ researchStatus: tool.schema.enum(["done", "read"]).optional().describe("For attachResearchFindings: optional explicit status to set on the matched research axis."),
119
127
  },
120
128
  async execute(args, context) {
121
129
  try {
@@ -124,8 +132,20 @@ export default tool({
124
132
  const defaultSlug = workspaceDeckSlug(workspaceRoot)
125
133
 
126
134
  if (args.action === "init") {
135
+ const discovered: SourceMaterial[] = []
127
136
  for (const material of (args.sourceMaterials ?? []) as SourceMaterial[]) {
128
137
  upsertSourceMaterial(state, material, material.status ?? "discovered")
138
+ discovered.push(material)
139
+ }
140
+ if (discovered.length > 0) {
141
+ recordWorkspaceAction(state, {
142
+ type: "source.discovered",
143
+ actor: "revela-decks",
144
+ inputs: { count: discovered.length },
145
+ outputs: { paths: discovered.map((material) => material.path), statuses: discovered.map((material) => material.status ?? "discovered") },
146
+ summary: `Registered ${discovered.length} discovered source material${discovered.length === 1 ? "" : "s"}.`,
147
+ nodeIds: discovered.map((material) => `source:${material.path}`),
148
+ })
129
149
  }
130
150
  writeDecksState(workspaceRoot, state)
131
151
  return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, state }, null, 2)
@@ -175,11 +195,50 @@ export default tool({
175
195
  }
176
196
 
177
197
  if (args.action === "review") {
178
- const reviewed = reviewDeckState(state)
198
+ const reviewed = reviewDeckState(state, undefined, { workspaceRoot })
199
+ const targetId = activeReviewTargetId(reviewed.state)
200
+ const snapshot = latestReviewSnapshotForTarget(reviewed.state, targetId)
201
+ recordWorkspaceAction(reviewed.state, {
202
+ type: "review.performed",
203
+ actor: "revela-decks",
204
+ inputs: { activeDeck: state.activeDeck },
205
+ outputs: {
206
+ slug: reviewed.result.slug,
207
+ status: reviewed.result.status,
208
+ ready: reviewed.result.ready,
209
+ blockerCount: reviewed.result.blockers.length,
210
+ warningCount: reviewed.result.warnings.length,
211
+ issueCount: reviewed.result.issues.length,
212
+ evidenceCandidateCount: reviewed.result.evidenceCandidates?.length ?? 0,
213
+ snapshotId: snapshot?.id,
214
+ inputHash: snapshot?.inputHash,
215
+ targetId: snapshot?.targetId,
216
+ },
217
+ status: "success",
218
+ summary: `Reviewed deck readiness: ${reviewed.result.ready ? "ready" : "blocked"}.`,
219
+ nodeIds: [`artifact:${reviewed.state.decks[reviewed.result.slug]?.outputPath ?? reviewed.result.slug}`, ...(snapshot ? [snapshot.id] : [])],
220
+ })
179
221
  writeDecksState(workspaceRoot, reviewed.state)
180
222
  return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result: reviewed.result }, null, 2)
181
223
  }
182
224
 
225
+ if (args.action === "applyEvidenceCandidates") {
226
+ const candidateIds = args.candidateIds ?? []
227
+ if (candidateIds.length === 0) return JSON.stringify({ ok: false, error: "candidateIds are required for applyEvidenceCandidates" })
228
+ const result = applyEvidenceBindings(workspaceRoot, candidateIds)
229
+ return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result }, null, 2)
230
+ }
231
+
232
+ if (args.action === "attachResearchFindings") {
233
+ if (!args.findingsFile?.trim()) return JSON.stringify({ ok: false, error: "findingsFile is required for attachResearchFindings" })
234
+ const result = attachResearchFindings(workspaceRoot, {
235
+ findingsFile: args.findingsFile,
236
+ researchAxis: args.researchAxis,
237
+ status: args.researchStatus,
238
+ })
239
+ return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result }, null, 2)
240
+ }
241
+
183
242
  if (args.action === "remember") {
184
243
  const memory = args.memory?.trim()
185
244
  if (!memory) return JSON.stringify({ ok: false, error: "memory is required for remember" })
@@ -0,0 +1,22 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import { compileInspectionContext } from "../lib/inspection-context/compile"
3
+ import { normalizeWorkspaceDeckState, readOrCreateDecksState } from "../lib/decks-state"
4
+
5
+ export default tool({
6
+ description:
7
+ "Compile Revela's current DECKS.json into structured inspection context for debugging and future Evidence Inspector flows. " +
8
+ "This is read-only: it does not write artifacts, mutate DECKS.json, or generate user-facing files.",
9
+ args: {
10
+ slug: tool.schema.string().optional().describe("Optional deck slug to compile. Defaults to the active workspace deck."),
11
+ },
12
+ async execute(args, context) {
13
+ try {
14
+ const workspaceRoot = context.directory ?? process.cwd()
15
+ const state = normalizeWorkspaceDeckState(readOrCreateDecksState(workspaceRoot), workspaceRoot)
16
+ const inspectionContext = compileInspectionContext(state, args.slug)
17
+ return JSON.stringify({ ok: true, inspectionContext }, null, 2)
18
+ } catch (e: any) {
19
+ return JSON.stringify({ ok: false, error: e.message || String(e) })
20
+ }
21
+ },
22
+ })