@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,426 @@
1
+ import { createHash } from "crypto"
2
+ import type { DeckSpec, DecksState, EvidenceRef, NarrativeBrief, ResearchAxis, SlideSpec, SourceMaterial } from "../decks-state"
3
+ import { renderTargetId } from "./render-targets"
4
+ import type { GraphEdge, GraphEdgeType, GraphNode, GraphNodeType, RenderTarget, WorkspaceGraph } from "./types"
5
+
6
+ export interface ProjectWorkspaceGraphOptions {
7
+ slug?: string
8
+ }
9
+
10
+ interface GraphBuilder {
11
+ nodes: Map<string, GraphNode>
12
+ edges: Map<string, GraphEdge>
13
+ }
14
+
15
+ export function projectWorkspaceGraph(state: DecksState, options: ProjectWorkspaceGraphOptions = {}): WorkspaceGraph {
16
+ const deck = activeDeck(state, options.slug)
17
+ const builder: GraphBuilder = { nodes: new Map(), edges: new Map() }
18
+
19
+ for (const material of state.workspace.sourceMaterials ?? []) addSourceMaterial(builder, material)
20
+ for (const axis of deck.researchPlan ?? []) addResearchFinding(builder, axis)
21
+
22
+ const narrativeId = addNarrative(builder, deck)
23
+ for (const slide of deck.slides.slice().sort((a, b) => a.index - b.index)) addSlide(builder, slide)
24
+ for (const slide of deck.slides.slice().sort((a, b) => a.index - b.index)) addSlideClaimsAndEvidence(builder, slide)
25
+ const targets = renderTargetsForDeck(state, deck)
26
+ for (const target of targets) addArtifact(builder, deck, target, narrativeId, targets)
27
+
28
+ return normalizeGraph(builder)
29
+ }
30
+
31
+ function activeDeck(state: DecksState, slug?: string): DeckSpec {
32
+ const key = slug || state.activeDeck || (Object.keys(state.decks).length === 1 ? Object.keys(state.decks)[0] : undefined)
33
+ if (!key || !state.decks[key]) throw new Error("No active deck is available for workspace graph projection.")
34
+ return state.decks[key]
35
+ }
36
+
37
+ function addSourceMaterial(builder: GraphBuilder, material: SourceMaterial): void {
38
+ const sourceId = sourceNodeId(material.path || material.fingerprint || "unknown-source")
39
+ addNode(builder, {
40
+ id: sourceId,
41
+ type: "source",
42
+ label: material.path,
43
+ data: compactData({
44
+ path: material.path,
45
+ type: material.type,
46
+ size: material.size,
47
+ fingerprint: material.fingerprint,
48
+ status: material.status,
49
+ summary: material.summary,
50
+ bestUsedFor: material.bestUsedFor,
51
+ }),
52
+ })
53
+
54
+ const extraction = material.extraction
55
+ if (!extraction) return
56
+ const extractionKey = extraction.manifestPath || extraction.textPath || extraction.cacheDir
57
+ if (!extractionKey) return
58
+
59
+ const extractionId = extractionNodeId(extractionKey)
60
+ addNode(builder, {
61
+ id: extractionId,
62
+ type: "extraction",
63
+ label: extractionKey,
64
+ data: compactData(extraction),
65
+ })
66
+ addEdge(builder, "extracted_as", sourceId, extractionId)
67
+ }
68
+
69
+ function addResearchFinding(builder: GraphBuilder, axis: ResearchAxis): void {
70
+ if (!axis.findingsFile?.trim()) return
71
+ const id = findingNodeId(axis.findingsFile)
72
+ addNode(builder, {
73
+ id,
74
+ type: "finding",
75
+ label: axis.findingsFile,
76
+ data: compactData({
77
+ axis: axis.axis,
78
+ needed: axis.needed,
79
+ status: axis.status,
80
+ findingsFile: axis.findingsFile,
81
+ notes: axis.notes,
82
+ sourceKind: "researchPlan",
83
+ }),
84
+ })
85
+ }
86
+
87
+ function addNarrative(builder: GraphBuilder, deck: DeckSpec): string | undefined {
88
+ const brief = deck.narrativeBrief
89
+ if (!hasNarrativeBrief(brief)) return undefined
90
+
91
+ const narrativeId = `narrative:${stableHash(deck.slug)}`
92
+ addNode(builder, {
93
+ id: narrativeId,
94
+ type: "narrativeIntent",
95
+ label: deck.goal || deck.slug,
96
+ data: compactData({
97
+ goal: deck.goal,
98
+ audience: deck.audience,
99
+ language: deck.language,
100
+ audienceBeliefBefore: brief?.audienceBeliefBefore,
101
+ audienceBeliefAfter: brief?.audienceBeliefAfter,
102
+ decisionOrAction: brief?.decisionOrAction,
103
+ narrativeArc: brief?.narrativeArc,
104
+ keyClaims: brief?.keyClaims,
105
+ }),
106
+ })
107
+
108
+ for (const objection of brief?.objections ?? []) {
109
+ const objectionId = `objection:${stableHash(objection)}`
110
+ addNode(builder, { id: objectionId, type: "objection", label: objection, data: { text: objection } })
111
+ addEdge(builder, "contains", narrativeId, objectionId)
112
+ addEdge(builder, "challenges", objectionId, narrativeId)
113
+ }
114
+
115
+ for (const risk of brief?.risks ?? []) {
116
+ const riskId = `risk:${stableHash(risk)}`
117
+ addNode(builder, { id: riskId, type: "risk", label: risk, data: { text: risk } })
118
+ addEdge(builder, "contains", narrativeId, riskId)
119
+ addEdge(builder, "constrained_by", narrativeId, riskId)
120
+ }
121
+
122
+ return narrativeId
123
+ }
124
+
125
+ function addSlide(builder: GraphBuilder, slide: SlideSpec): void {
126
+ addNode(builder, {
127
+ id: slideNodeId(slide.index),
128
+ type: "slide",
129
+ label: slide.title,
130
+ data: compactData({
131
+ index: slide.index,
132
+ title: slide.title,
133
+ purpose: slide.purpose,
134
+ narrativeRole: slide.narrativeRole,
135
+ layout: slide.layout,
136
+ components: slide.components,
137
+ status: slide.status,
138
+ }),
139
+ })
140
+ }
141
+
142
+ function addSlideClaimsAndEvidence(builder: GraphBuilder, slide: SlideSpec): void {
143
+ const slideId = slideNodeId(slide.index)
144
+ const claims = claimCandidates(slide)
145
+ const claimIds = claims.map((claim) => addClaim(builder, slide, claim))
146
+
147
+ for (const claimId of claimIds) {
148
+ addEdge(builder, "contains", slideId, claimId)
149
+ addEdge(builder, "appears_in", claimId, slideId)
150
+ }
151
+
152
+ for (const evidence of slide.evidence ?? []) {
153
+ const supportId = addEvidenceSupportNode(builder, evidence)
154
+ for (const claimId of claimIds) {
155
+ addEdge(builder, "supports", supportId, claimId, compactData({
156
+ slideIndex: slide.index,
157
+ detailLevel: hasEvidenceDetail(evidence) ? "detailed" : "weak",
158
+ source: evidence.source,
159
+ quote: evidence.quote,
160
+ page: evidence.page,
161
+ url: evidence.url,
162
+ sourcePath: evidence.sourcePath,
163
+ location: evidence.location,
164
+ findingsFile: evidence.findingsFile,
165
+ caveat: evidence.caveat,
166
+ extractedTextPath: evidence.extractedTextPath,
167
+ extractedManifestPath: evidence.extractedManifestPath,
168
+ }))
169
+ }
170
+ }
171
+ }
172
+
173
+ function addClaim(builder: GraphBuilder, slide: SlideSpec, claim: { origin: string; text: string }): string {
174
+ const id = claimNodeId(slide.index, claim.text)
175
+ addNode(builder, {
176
+ id,
177
+ type: "claim",
178
+ label: claim.text,
179
+ data: compactData({
180
+ slideIndex: slide.index,
181
+ slideTitle: slide.title,
182
+ origin: claim.origin,
183
+ text: claim.text,
184
+ }),
185
+ })
186
+ return id
187
+ }
188
+
189
+ function addEvidenceSupportNode(builder: GraphBuilder, evidence: EvidenceRef): string {
190
+ if (evidence.findingsFile?.trim()) {
191
+ const id = findingNodeId(evidence.findingsFile)
192
+ addNode(builder, {
193
+ id,
194
+ type: "finding",
195
+ label: evidence.findingsFile,
196
+ data: compactData({ findingsFile: evidence.findingsFile, source: evidence.source, quote: evidence.quote, location: evidence.location, caveat: evidence.caveat }),
197
+ })
198
+ return id
199
+ }
200
+
201
+ const sourceKey = evidence.sourcePath || evidence.source || evidence.url || "unknown-evidence-source"
202
+ const id = sourceNodeId(sourceKey)
203
+ addNode(builder, {
204
+ id,
205
+ type: "source",
206
+ label: sourceKey,
207
+ data: compactData({ source: evidence.source, sourcePath: evidence.sourcePath, url: evidence.url }),
208
+ })
209
+
210
+ if (evidence.extractedTextPath || evidence.extractedManifestPath) {
211
+ const extractionKey = evidence.extractedTextPath || evidence.extractedManifestPath
212
+ const extractionId = extractionNodeId(extractionKey ?? sourceKey)
213
+ addNode(builder, {
214
+ id: extractionId,
215
+ type: "extraction",
216
+ label: extractionKey,
217
+ data: compactData({ textPath: evidence.extractedTextPath, manifestPath: evidence.extractedManifestPath }),
218
+ })
219
+ addEdge(builder, "extracted_as", id, extractionId)
220
+ }
221
+
222
+ return id
223
+ }
224
+
225
+ function addArtifact(builder: GraphBuilder, deck: DeckSpec, target: RenderTarget, narrativeId: string | undefined, targets: RenderTarget[]): void {
226
+ const artifactId = artifactNodeId(target.outputPath ?? deck.outputPath)
227
+ addNode(builder, {
228
+ id: artifactId,
229
+ type: "artifact",
230
+ label: target.outputPath ?? deck.outputPath,
231
+ data: compactData({
232
+ renderTargetId: target.id,
233
+ type: target.type,
234
+ outputPath: target.outputPath ?? deck.outputPath,
235
+ slug: deck.slug,
236
+ status: deck.status,
237
+ artifactVersion: target.artifactVersion,
238
+ contractStatus: target.contractStatus,
239
+ }),
240
+ })
241
+
242
+ if (narrativeId) addEdge(builder, "renders_from", artifactId, narrativeId)
243
+ const sourceNodeIds = target.sourceNodeIds.length > 0 ? target.sourceNodeIds : deck.slides.map((slide) => slideNodeId(slide.index))
244
+ for (const sourceNodeId of sourceNodeIds) addEdge(builder, "renders_from", artifactId, resolveRenderSourceNodeId(sourceNodeId, targets))
245
+ }
246
+
247
+ function renderTargetsForDeck(state: DecksState, deck: DeckSpec): RenderTarget[] {
248
+ const deckOutputPath = normalizePath(deck.outputPath)
249
+ const htmlTargetId = renderTargetId("html_deck", deckOutputPath)
250
+ const htmlArtifactId = artifactNodeId(deckOutputPath)
251
+ const targets = (state.renderTargets ?? []).filter((target) => {
252
+ if (target.id === htmlTargetId) return true
253
+ if (target.type === "html_deck") return normalizePath(target.outputPath ?? "") === deckOutputPath
254
+ const data = target.data ?? {}
255
+ return data.sourceTargetId === htmlTargetId ||
256
+ data.sourceOutputPath === deckOutputPath ||
257
+ target.sourceNodeIds.includes(htmlTargetId) ||
258
+ target.sourceNodeIds.includes(htmlArtifactId)
259
+ })
260
+ const htmlTarget = targets.find((target) => target.id === htmlTargetId) ?? fallbackHtmlDeckRenderTarget(deck)
261
+ if (!targets.some((target) => target.id === htmlTarget.id)) targets.push(htmlTarget)
262
+ return targets.sort((a, b) => a.id.localeCompare(b.id))
263
+ }
264
+
265
+ function resolveRenderSourceNodeId(sourceNodeId: string, targets: RenderTarget[]): string {
266
+ if (!sourceNodeId.startsWith("target:")) return sourceNodeId
267
+ const target = targets.find((item) => item.id === sourceNodeId)
268
+ return target ? artifactNodeId(target.outputPath ?? target.id) : sourceNodeId
269
+ }
270
+
271
+ function fallbackHtmlDeckRenderTarget(deck: DeckSpec): RenderTarget {
272
+ return {
273
+ id: renderTargetId("html_deck", deck.outputPath),
274
+ type: "html_deck",
275
+ outputPath: deck.outputPath,
276
+ sourceNodeIds: deck.slides.map((slide) => slideNodeId(slide.index)),
277
+ contractStatus: "unknown",
278
+ data: { slug: deck.slug, compatibilityOutputPath: deck.outputPath },
279
+ }
280
+ }
281
+
282
+ function claimCandidates(slide: SlideSpec): Array<{ origin: string; text: string }> {
283
+ const claims: Array<{ origin: string; text: string }> = []
284
+ pushClaim(claims, "title", slide.title)
285
+ pushClaim(claims, "purpose", slide.purpose)
286
+ pushClaim(claims, "headline", slide.content?.headline)
287
+ for (const item of slide.content?.body ?? []) pushClaim(claims, "body", item)
288
+ for (const item of slide.content?.bullets ?? []) pushClaim(claims, "bullet", item)
289
+ return claims
290
+ }
291
+
292
+ function pushClaim(claims: Array<{ origin: string; text: string }>, origin: string, text: string | undefined): void {
293
+ const value = cleanOptionalText(text)
294
+ if (!value) return
295
+ if (claims.some((claim) => claim.text === value)) return
296
+ claims.push({ origin, text: value })
297
+ }
298
+
299
+ function hasNarrativeBrief(brief: NarrativeBrief | undefined): boolean {
300
+ return Boolean(
301
+ brief?.audienceBeliefBefore?.trim() ||
302
+ brief?.audienceBeliefAfter?.trim() ||
303
+ brief?.decisionOrAction?.trim() ||
304
+ brief?.narrativeArc?.trim() ||
305
+ brief?.keyClaims.length ||
306
+ brief?.objections.length ||
307
+ brief?.risks.length,
308
+ )
309
+ }
310
+
311
+ function hasEvidenceDetail(evidence: EvidenceRef): boolean {
312
+ return Boolean(
313
+ evidence.quote?.trim() ||
314
+ evidence.page?.trim() ||
315
+ evidence.location?.trim() ||
316
+ evidence.url?.trim() ||
317
+ evidence.findingsFile?.trim() ||
318
+ evidence.sourcePath?.trim() ||
319
+ evidence.extractedTextPath?.trim(),
320
+ )
321
+ }
322
+
323
+ function addNode(builder: GraphBuilder, node: GraphNode): void {
324
+ const existing = builder.nodes.get(node.id)
325
+ if (!existing) {
326
+ builder.nodes.set(node.id, cleanNode(node))
327
+ return
328
+ }
329
+ builder.nodes.set(node.id, cleanNode({
330
+ ...existing,
331
+ label: existing.label || node.label,
332
+ data: compactData({ ...(existing.data ?? {}), ...(node.data ?? {}) }),
333
+ }))
334
+ }
335
+
336
+ function addEdge(builder: GraphBuilder, type: GraphEdgeType, from: string, to: string, data?: Record<string, unknown>): void {
337
+ const cleanedData = compactData(data ?? {})
338
+ const edge: GraphEdge = {
339
+ id: edgeId(type, from, to, cleanedData),
340
+ type,
341
+ from,
342
+ to,
343
+ ...(Object.keys(cleanedData).length > 0 ? { data: cleanedData } : {}),
344
+ }
345
+ builder.edges.set(edge.id, edge)
346
+ }
347
+
348
+ function normalizeGraph(builder: GraphBuilder): WorkspaceGraph {
349
+ const nodes = [...builder.nodes.values()].sort((a, b) => a.id.localeCompare(b.id))
350
+ return {
351
+ nodes: Object.fromEntries(nodes.map((node) => [node.id, node])),
352
+ edges: [...builder.edges.values()].sort((a, b) => a.id.localeCompare(b.id)),
353
+ }
354
+ }
355
+
356
+ function cleanNode(node: GraphNode): GraphNode {
357
+ const data = compactData(node.data ?? {})
358
+ return {
359
+ id: node.id,
360
+ type: node.type as GraphNodeType,
361
+ ...(node.label ? { label: node.label } : {}),
362
+ ...(Object.keys(data).length > 0 ? { data } : {}),
363
+ }
364
+ }
365
+
366
+ function compactData(input: Record<string, unknown>): Record<string, unknown> {
367
+ const output: Record<string, unknown> = {}
368
+ for (const [key, value] of Object.entries(input)) {
369
+ if (value === undefined || value === null) continue
370
+ if (typeof value === "string" && value.trim() === "") continue
371
+ if (Array.isArray(value) && value.length === 0) continue
372
+ output[key] = value
373
+ }
374
+ return output
375
+ }
376
+
377
+ function sourceNodeId(value: string): string {
378
+ return `source:${stablePathOrHash(value)}`
379
+ }
380
+
381
+ function extractionNodeId(value: string): string {
382
+ return `extraction:${stablePathOrHash(value)}`
383
+ }
384
+
385
+ function findingNodeId(value: string): string {
386
+ return `finding:${stablePathOrHash(value)}`
387
+ }
388
+
389
+ function claimNodeId(slideIndex: number, text: string): string {
390
+ return `claim:${slideIndex}:${stableHash(normalizeText(text))}`
391
+ }
392
+
393
+ function slideNodeId(index: number): string {
394
+ return `slide:${index}`
395
+ }
396
+
397
+ function artifactNodeId(outputPath: string): string {
398
+ return `artifact:${stablePathOrHash(outputPath)}`
399
+ }
400
+
401
+ function edgeId(type: GraphEdgeType, from: string, to: string, data: Record<string, unknown>): string {
402
+ return `edge:${type}:${stableHash(JSON.stringify({ from, to, data }))}`
403
+ }
404
+
405
+ function stablePathOrHash(value: string): string {
406
+ const normalized = normalizePath(value)
407
+ if (/^[a-z0-9._/-]+$/i.test(normalized) && normalized.length <= 80) return normalized
408
+ return stableHash(normalized)
409
+ }
410
+
411
+ function stableHash(value: string): string {
412
+ return createHash("sha1").update(value).digest("hex").slice(0, 12)
413
+ }
414
+
415
+ function normalizePath(value: string): string {
416
+ return value.trim().replace(/\\/g, "/").replace(/^\.\//, "")
417
+ }
418
+
419
+ function normalizeText(value: string): string {
420
+ return value.trim().replace(/\s+/g, " ").toLowerCase()
421
+ }
422
+
423
+ function cleanOptionalText(value: string | undefined): string | undefined {
424
+ const text = String(value ?? "").trim()
425
+ return text || undefined
426
+ }
@@ -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
+ }