@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 +25 -5
- package/README.zh-CN.md +25 -5
- package/lib/commands/inspect.ts +1 -1
- package/lib/commands/pdf.ts +33 -5
- package/lib/commands/pptx.ts +14 -9
- package/lib/commands/refine.ts +1 -1
- package/lib/deck-html/contract.ts +252 -0
- package/lib/decks-state.ts +101 -28
- package/lib/document-materials/extract.ts +20 -0
- package/lib/edit/resolve-deck.ts +13 -2
- package/lib/inspect/open.ts +3 -1
- package/lib/qa/export-gate.ts +8 -1
- package/lib/refine/open.ts +3 -1
- package/lib/workspace-state/actions.ts +71 -0
- package/lib/workspace-state/compat.ts +10 -0
- package/lib/workspace-state/evidence-status.ts +267 -0
- package/lib/workspace-state/graph.ts +426 -0
- package/lib/workspace-state/render-targets.ts +182 -0
- package/lib/workspace-state/rendered-artifacts.ts +43 -0
- package/lib/workspace-state/repository.ts +43 -0
- package/lib/workspace-state/research-attachments.ts +130 -0
- package/lib/workspace-state/review-snapshots.ts +127 -0
- package/lib/workspace-state/types.ts +119 -0
- package/package.json +1 -1
- package/plugin.ts +26 -1
- package/tools/decks.ts +54 -5
- package/tools/pdf.ts +9 -1
- package/tools/pptx.ts +10 -0
- package/tools/research-save.ts +15 -0
- package/tools/workspace-scan.ts +15 -0
|
@@ -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,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
|