@cyber-dash-tech/revela 0.10.0 → 0.12.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 (44) hide show
  1. package/README.md +54 -28
  2. package/README.zh-CN.md +54 -28
  3. package/lib/commands/designs.ts +2 -2
  4. package/lib/commands/domains.ts +2 -2
  5. package/lib/commands/enable.ts +19 -19
  6. package/lib/commands/help.ts +5 -3
  7. package/lib/commands/init.ts +30 -19
  8. package/lib/commands/inspect.ts +1 -1
  9. package/lib/commands/pdf.ts +33 -5
  10. package/lib/commands/pptx.ts +14 -9
  11. package/lib/commands/refine.ts +1 -1
  12. package/lib/commands/review.ts +115 -1
  13. package/lib/deck-html/contract.ts +252 -0
  14. package/lib/decks-state.ts +111 -28
  15. package/lib/document-materials/extract.ts +20 -0
  16. package/lib/edit/resolve-deck.ts +13 -2
  17. package/lib/inspect/open.ts +3 -1
  18. package/lib/narrative-state/hash.ts +52 -0
  19. package/lib/narrative-state/normalize.ts +307 -0
  20. package/lib/narrative-state/project-compat.ts +14 -0
  21. package/lib/narrative-state/readiness.ts +289 -0
  22. package/lib/narrative-state/render-plan.ts +207 -0
  23. package/lib/narrative-state/types.ts +139 -0
  24. package/lib/prompt-builder.ts +59 -26
  25. package/lib/qa/export-gate.ts +8 -1
  26. package/lib/refine/open.ts +3 -1
  27. package/lib/workspace-state/actions.ts +71 -0
  28. package/lib/workspace-state/compat.ts +10 -0
  29. package/lib/workspace-state/evidence-status.ts +267 -0
  30. package/lib/workspace-state/graph.ts +544 -0
  31. package/lib/workspace-state/render-targets.ts +182 -0
  32. package/lib/workspace-state/rendered-artifacts.ts +43 -0
  33. package/lib/workspace-state/repository.ts +43 -0
  34. package/lib/workspace-state/research-attachments.ts +130 -0
  35. package/lib/workspace-state/review-snapshots.ts +127 -0
  36. package/lib/workspace-state/types.ts +122 -0
  37. package/package.json +1 -1
  38. package/plugin.ts +53 -3
  39. package/skill/NARRATIVE_SKILL.md +64 -0
  40. package/tools/decks.ts +233 -6
  41. package/tools/pdf.ts +9 -1
  42. package/tools/pptx.ts +10 -0
  43. package/tools/research-save.ts +15 -0
  44. package/tools/workspace-scan.ts +29 -1
@@ -0,0 +1,182 @@
1
+ import { createHash } from "crypto"
2
+ import { basename, dirname, extname, join } from "path"
3
+ import type { DeckSpec, DecksState } from "../decks-state"
4
+ import type { RenderTarget } from "./types"
5
+
6
+ export type RenderTargetType = RenderTarget["type"]
7
+
8
+ export function activeHtmlDeckRenderTarget(state: DecksState): RenderTarget | undefined {
9
+ const deck = activeDeck(state)
10
+ if (!deck) return undefined
11
+ const expectedPath = normalizeWorkspacePath(deck.outputPath)
12
+ return (state.renderTargets ?? []).find((target) =>
13
+ target.type === "html_deck" && normalizeWorkspacePath(target.outputPath ?? "") === expectedPath
14
+ )
15
+ }
16
+
17
+ export function ensureActiveHtmlDeckRenderTarget(state: DecksState): RenderTarget | undefined {
18
+ const deck = activeDeck(state)
19
+ if (!deck?.outputPath) return undefined
20
+ state.renderTargets ??= []
21
+
22
+ const target = createHtmlDeckRenderTarget(deck)
23
+ const index = state.renderTargets.findIndex((item) => item.id === target.id)
24
+ if (index >= 0) {
25
+ state.renderTargets[index] = mergeRenderTarget(state.renderTargets[index], target)
26
+ } else {
27
+ state.renderTargets.push(target)
28
+ }
29
+
30
+ state.renderTargets = sortRenderTargets(state.renderTargets)
31
+ return state.renderTargets.find((item) => item.id === target.id)
32
+ }
33
+
34
+ export function resolveActiveHtmlDeckPath(state: DecksState): string | undefined {
35
+ const target = activeHtmlDeckRenderTarget(state) ?? ensureActiveHtmlDeckRenderTarget(state)
36
+ if (target?.outputPath) return normalizeWorkspacePath(target.outputPath)
37
+ return activeDeck(state)?.outputPath ? normalizeWorkspacePath(activeDeck(state)?.outputPath ?? "") : undefined
38
+ }
39
+
40
+ export function upsertRenderTarget(state: DecksState, target: RenderTarget): DecksState {
41
+ state.renderTargets ??= []
42
+ const cleaned = cleanRenderTarget(target)
43
+ const index = state.renderTargets.findIndex((item) => item.id === cleaned.id)
44
+ if (index >= 0) state.renderTargets[index] = mergeRenderTarget(state.renderTargets[index], cleaned)
45
+ else state.renderTargets.push(cleaned)
46
+ state.renderTargets = sortRenderTargets(state.renderTargets)
47
+ return state
48
+ }
49
+
50
+ export function deriveExportRenderTarget(htmlTarget: RenderTarget, type: "pdf" | "pptx", outputPath: string): RenderTarget {
51
+ const normalizedOutput = normalizeWorkspacePath(outputPath)
52
+ return cleanRenderTarget({
53
+ id: renderTargetId(type, normalizedOutput),
54
+ type,
55
+ outputPath: normalizedOutput,
56
+ sourceNodeIds: htmlTarget.outputPath ? [artifactNodeIdForRenderTarget(htmlTarget)] : htmlTarget.sourceNodeIds,
57
+ contractStatus: "unknown",
58
+ data: {
59
+ sourceTargetId: htmlTarget.id,
60
+ sourceOutputPath: htmlTarget.outputPath,
61
+ },
62
+ })
63
+ }
64
+
65
+ export function recordArtifactRenderTarget(
66
+ state: DecksState,
67
+ input: { sourceHtmlPath: string; type: "pdf" | "pptx"; outputPath: string; artifactVersion?: string },
68
+ ): RenderTarget {
69
+ const normalizedSource = normalizeWorkspacePath(input.sourceHtmlPath)
70
+ const activeTarget = ensureActiveHtmlDeckRenderTarget(state)
71
+ const htmlTarget = htmlDeckRenderTargetForPath(state, normalizedSource) ?? (
72
+ activeTarget && normalizeWorkspacePath(activeTarget.outputPath ?? "") === normalizedSource ? activeTarget : undefined
73
+ )
74
+ const sourceTarget = htmlTarget ?? {
75
+ id: renderTargetId("html_deck", normalizedSource),
76
+ type: "html_deck" as const,
77
+ outputPath: normalizedSource,
78
+ sourceNodeIds: [],
79
+ contractStatus: "unknown" as const,
80
+ }
81
+ const target = {
82
+ ...deriveExportRenderTarget(sourceTarget, input.type, input.outputPath),
83
+ ...(input.artifactVersion ? { artifactVersion: input.artifactVersion } : {}),
84
+ }
85
+ upsertRenderTarget(state, target)
86
+ return target
87
+ }
88
+
89
+ export function htmlDeckRenderTargetForPath(state: DecksState, htmlPath: string): RenderTarget | undefined {
90
+ const normalized = normalizeWorkspacePath(htmlPath)
91
+ return (state.renderTargets ?? []).find((target) =>
92
+ target.type === "html_deck" && normalizeWorkspacePath(target.outputPath ?? "") === normalized
93
+ )
94
+ }
95
+
96
+ export function renderTargetId(type: RenderTargetType, outputPath: string): string {
97
+ return `target:${type}:${stablePathOrHash(outputPath)}`
98
+ }
99
+
100
+ export function artifactNodeIdForRenderTarget(target: RenderTarget): string {
101
+ return `artifact:${stablePathOrHash(target.outputPath || target.id)}`
102
+ }
103
+
104
+ export function normalizeWorkspacePath(value: string): string {
105
+ return String(value ?? "").trim().replace(/\\/g, "/").replace(/^\.\//, "")
106
+ }
107
+
108
+ export function replaceExtension(filePath: string, extension: ".pdf" | ".pptx"): string {
109
+ const normalized = normalizeWorkspacePath(filePath)
110
+ const dir = dirname(normalized)
111
+ const name = basename(normalized, extname(normalized))
112
+ return normalizeWorkspacePath(join(dir, `${name}${extension}`))
113
+ }
114
+
115
+ function createHtmlDeckRenderTarget(deck: DeckSpec): RenderTarget {
116
+ const outputPath = normalizeWorkspacePath(deck.outputPath)
117
+ const slideNodeIds = deck.slides.map((slide) => `slide:${slide.index}`)
118
+ return cleanRenderTarget({
119
+ id: renderTargetId("html_deck", outputPath),
120
+ type: "html_deck",
121
+ outputPath,
122
+ sourceNodeIds: [...new Set(slideNodeIds)].sort(),
123
+ contractStatus: "unknown",
124
+ data: {
125
+ slug: deck.slug,
126
+ compatibilityOutputPath: outputPath,
127
+ },
128
+ })
129
+ }
130
+
131
+ function activeDeck(state: DecksState): DeckSpec | undefined {
132
+ const key = state.activeDeck || singleDeckKey(state.decks)
133
+ return key ? state.decks[key] : undefined
134
+ }
135
+
136
+ function singleDeckKey(decks: Record<string, DeckSpec>): string | undefined {
137
+ const keys = Object.keys(decks)
138
+ return keys.length === 1 ? keys[0] : undefined
139
+ }
140
+
141
+ function cleanRenderTarget(target: RenderTarget): RenderTarget {
142
+ const data = compactData(target.data ?? {})
143
+ return {
144
+ id: target.id || renderTargetId(target.type, target.outputPath || "unknown"),
145
+ type: target.type,
146
+ ...(target.outputPath ? { outputPath: normalizeWorkspacePath(target.outputPath) } : {}),
147
+ sourceNodeIds: [...new Set(target.sourceNodeIds ?? [])].sort(),
148
+ ...(target.artifactVersion ? { artifactVersion: target.artifactVersion } : {}),
149
+ ...(target.contractStatus ? { contractStatus: target.contractStatus } : {}),
150
+ ...(Object.keys(data).length > 0 ? { data } : {}),
151
+ }
152
+ }
153
+
154
+ function mergeRenderTarget(existing: RenderTarget, next: RenderTarget): RenderTarget {
155
+ return cleanRenderTarget({
156
+ ...existing,
157
+ ...next,
158
+ sourceNodeIds: next.sourceNodeIds.length > 0 ? next.sourceNodeIds : existing.sourceNodeIds,
159
+ data: { ...(existing.data ?? {}), ...(next.data ?? {}) },
160
+ })
161
+ }
162
+
163
+ function sortRenderTargets(targets: RenderTarget[]): RenderTarget[] {
164
+ return targets.map(cleanRenderTarget).sort((a, b) => a.id.localeCompare(b.id))
165
+ }
166
+
167
+ function compactData(input: Record<string, unknown>): Record<string, unknown> {
168
+ const output: Record<string, unknown> = {}
169
+ for (const [key, value] of Object.entries(input)) {
170
+ if (value === undefined || value === null) continue
171
+ if (typeof value === "string" && value.trim() === "") continue
172
+ if (Array.isArray(value) && value.length === 0) continue
173
+ output[key] = value
174
+ }
175
+ return output
176
+ }
177
+
178
+ function stablePathOrHash(value: string): string {
179
+ const normalized = normalizeWorkspacePath(value)
180
+ if (/^[a-z0-9._/-]+$/i.test(normalized) && normalized.length <= 80) return normalized
181
+ return createHash("sha1").update(normalized).digest("hex").slice(0, 12)
182
+ }
@@ -0,0 +1,43 @@
1
+ import { relative, resolve, sep } from "path"
2
+ import { hasDecksState, readDecksState, writeDecksState } from "../decks-state"
3
+ import { recordWorkspaceAction } from "./actions"
4
+ import { recordArtifactRenderTarget } from "./render-targets"
5
+
6
+ export function recordRenderedArtifact(
7
+ workspaceRoot: string,
8
+ input: {
9
+ sourceHtmlPath: string
10
+ outputPath: string
11
+ type: "pdf" | "pptx"
12
+ actor: string
13
+ artifactVersion?: string
14
+ },
15
+ ): void {
16
+ const root = resolve(workspaceRoot)
17
+ if (!hasDecksState(root)) return
18
+
19
+ const state = readDecksState(root)
20
+ const sourceHtmlPath = workspaceRelative(root, resolve(root, input.sourceHtmlPath))
21
+ const outputPath = workspaceRelative(root, resolve(root, input.outputPath))
22
+ const target = recordArtifactRenderTarget(state, {
23
+ sourceHtmlPath,
24
+ type: input.type,
25
+ outputPath,
26
+ artifactVersion: input.artifactVersion,
27
+ })
28
+
29
+ recordWorkspaceAction(state, {
30
+ type: "artifact.rendered",
31
+ actor: input.actor,
32
+ inputs: { sourceHtmlPath, type: input.type },
33
+ outputs: { outputPath, targetId: target.id },
34
+ status: "success",
35
+ summary: `Rendered ${input.type.toUpperCase()} artifact from ${sourceHtmlPath}.`,
36
+ nodeIds: [target.id],
37
+ })
38
+ writeDecksState(root, state)
39
+ }
40
+
41
+ export function workspaceRelative(root: string, target: string): string {
42
+ return relative(root, target).split(sep).join("/")
43
+ }
@@ -0,0 +1,43 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
2
+ import { dirname, join } from "path"
3
+ import { WORKSPACE_STATE_FILE, type WorkspaceStateRepositoryOptions } from "./types"
4
+
5
+ export function workspaceStatePath(workspaceRoot: string, fileName = WORKSPACE_STATE_FILE): string {
6
+ return join(workspaceRoot, fileName)
7
+ }
8
+
9
+ export function hasWorkspaceState(workspaceRoot: string, fileName = WORKSPACE_STATE_FILE): boolean {
10
+ return existsSync(workspaceStatePath(workspaceRoot, fileName))
11
+ }
12
+
13
+ export function readWorkspaceState<TState>(workspaceRoot: string, options: WorkspaceStateRepositoryOptions<TState> = {}): TState {
14
+ const parsed = JSON.parse(readFileSync(workspaceStatePath(workspaceRoot, options.fileName), "utf-8")) as TState
15
+ return options.normalize ? options.normalize(parsed) : parsed
16
+ }
17
+
18
+ export function writeWorkspaceState<TState>(workspaceRoot: string, state: TState, options: WorkspaceStateRepositoryOptions<TState> = {}): void {
19
+ const filePath = workspaceStatePath(workspaceRoot, options.fileName)
20
+ const next = options.normalize ? options.normalize(state) : state
21
+ mkdirSync(dirname(filePath), { recursive: true })
22
+ writeFileSync(filePath, JSON.stringify(next, null, 2) + "\n", "utf-8")
23
+ }
24
+
25
+ export function readOrCreateWorkspaceState<TState>(
26
+ workspaceRoot: string,
27
+ createState: () => TState,
28
+ options: WorkspaceStateRepositoryOptions<TState> = {},
29
+ ): TState {
30
+ if (hasWorkspaceState(workspaceRoot, options.fileName)) return readWorkspaceState(workspaceRoot, options)
31
+
32
+ const state = createState()
33
+ writeWorkspaceState(workspaceRoot, state, options)
34
+ return state
35
+ }
36
+
37
+ export function loadCanonicalState<TState>(workspaceRoot: string, options: WorkspaceStateRepositoryOptions<TState> = {}): TState {
38
+ return readWorkspaceState(workspaceRoot, options)
39
+ }
40
+
41
+ export function saveCanonicalState<TState>(workspaceRoot: string, state: TState, options: WorkspaceStateRepositoryOptions<TState> = {}): void {
42
+ writeWorkspaceState(workspaceRoot, state, options)
43
+ }
@@ -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,122 @@
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
+ | "narrative.upserted"
92
+ | "deck.plan_compiled"
93
+ | "evidence.candidate_generated"
94
+ | "evidence.binding_applied"
95
+ | "narrative.approved"
96
+ | "review.performed"
97
+ | "artifact.rendered"
98
+
99
+ export interface RenderTarget {
100
+ id: string
101
+ type: "html_deck" | "pdf" | "pptx" | "brief" | "appendix" | "qa_view" | "interactive_page"
102
+ outputPath?: string
103
+ sourceNodeIds: string[]
104
+ artifactVersion?: string
105
+ contractStatus?: "unknown" | "valid" | "invalid" | "stale"
106
+ data?: Record<string, unknown>
107
+ }
108
+
109
+ export interface ReviewSnapshot {
110
+ id: string
111
+ targetId?: string
112
+ inputHash: string
113
+ status: "blocked" | "ready" | "written"
114
+ blockers: string[]
115
+ warnings: string[]
116
+ issues: unknown[]
117
+ evidenceCandidates?: unknown[]
118
+ reviewedAt: string
119
+ }
120
+
121
+ export type DecksStateV1Projection = DecksState
122
+ 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.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",