@cyber-dash-tech/revela 0.17.0 → 0.17.2
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 +45 -567
- package/README.zh-CN.md +47 -535
- package/designs/monet/DESIGN.md +1 -1
- package/designs/starter/DESIGN.md +1 -1
- package/designs/summit/DESIGN.md +1 -1
- package/lib/commands/help.ts +4 -4
- package/lib/commands/init.ts +9 -7
- package/lib/commands/narrative.ts +13 -4
- package/lib/commands/pdf.ts +24 -15
- package/lib/commands/pptx.ts +2 -16
- package/lib/commands/research.ts +2 -0
- package/lib/commands/review.ts +81 -93
- package/lib/deck-html/contract.ts +26 -10
- package/lib/decks-state.ts +75 -86
- package/lib/edit/deck-state.ts +3 -111
- package/lib/edit/open.ts +2 -2
- package/lib/edit/resolve-deck.ts +14 -24
- package/lib/inspect/open.ts +2 -2
- package/lib/media/download.ts +23 -3
- package/lib/media/save.ts +1 -0
- package/lib/media/types.ts +1 -0
- package/lib/narrative-state/deck-plan-artifact.ts +584 -0
- package/lib/narrative-state/display.ts +74 -4
- package/lib/narrative-state/map-html.ts +242 -107
- package/lib/narrative-state/render-plan.ts +649 -44
- package/lib/narrative-state/research-gaps.ts +5 -2
- package/lib/narrative-vault/compile.ts +16 -1
- package/lib/narrative-vault/types.ts +4 -2
- package/lib/qa/checks.ts +206 -5
- package/lib/qa/measure.ts +63 -1
- package/lib/refine/open.ts +2 -2
- package/lib/refine/server.ts +157 -20
- package/package.json +1 -1
- package/plugin.ts +2 -2
- package/skill/NARRATIVE_SKILL.md +19 -19
- package/skill/SKILL.md +99 -35
- package/tools/decks.ts +83 -51
- package/tools/narrative-view.ts +16 -0
|
@@ -0,0 +1,584 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs"
|
|
2
|
+
import { dirname, join, relative } from "path"
|
|
3
|
+
import { createHash } from "crypto"
|
|
4
|
+
import type { DeckSpec, SlideSpec } from "../decks-state"
|
|
5
|
+
import { parseVaultFrontmatter } from "../narrative-vault/frontmatter"
|
|
6
|
+
import { splitMarkdownSections } from "../narrative-vault/markdown"
|
|
7
|
+
import { stableVaultRelationId } from "../narrative-vault/relations"
|
|
8
|
+
import type { VaultRelation, WorkspaceGraphNodeType } from "../narrative-vault/types"
|
|
9
|
+
import type { DeckPlanChapter, DeckPlanQualityCheck, RenderPlanContract, RenderPlanSlideMetadata } from "./render-plan"
|
|
10
|
+
|
|
11
|
+
export const DECK_PLAN_DIR = "deck-plan"
|
|
12
|
+
export const DECK_PLAN_INDEX_PATH = "deck-plan/index.md"
|
|
13
|
+
export const DECK_PLAN_SLIDES_DIR = "deck-plan/slides"
|
|
14
|
+
export const LEGACY_DECK_PLAN_ARTIFACT_PATH = "decks/deck-plan.md"
|
|
15
|
+
export const DECK_PLAN_ARTIFACT_PATH = DECK_PLAN_INDEX_PATH
|
|
16
|
+
|
|
17
|
+
export interface DeckPlanArtifactInput {
|
|
18
|
+
deck: DeckSpec
|
|
19
|
+
narrativeHash: string
|
|
20
|
+
planHash: string
|
|
21
|
+
chapters: DeckPlanChapter[]
|
|
22
|
+
qualityChecks: DeckPlanQualityCheck[]
|
|
23
|
+
renderPlan?: RenderPlanContract
|
|
24
|
+
compiledAt: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface DeckPlanApproval {
|
|
28
|
+
status?: string
|
|
29
|
+
approvedBy?: string
|
|
30
|
+
approvedAt?: string
|
|
31
|
+
approvalNote?: string
|
|
32
|
+
planHash?: string
|
|
33
|
+
narrativeHash?: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export interface DeckPlanApprovalValidation {
|
|
37
|
+
ok: boolean
|
|
38
|
+
reason?: string
|
|
39
|
+
approval?: DeckPlanApproval
|
|
40
|
+
planHash?: string
|
|
41
|
+
sections?: string[]
|
|
42
|
+
missingSections?: string[]
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface DeckPlanReadResult {
|
|
46
|
+
ok: boolean
|
|
47
|
+
path: string
|
|
48
|
+
absolutePath: string
|
|
49
|
+
markdown?: string
|
|
50
|
+
planHash?: string
|
|
51
|
+
approval?: DeckPlanApproval
|
|
52
|
+
approvalStatus: "missing" | "pending" | "approved" | "stale" | "invalid"
|
|
53
|
+
sections: string[]
|
|
54
|
+
missingSections: string[]
|
|
55
|
+
warnings: string[]
|
|
56
|
+
reason?: string
|
|
57
|
+
projection?: DeckPlanProjection
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface DeckPlanProjection {
|
|
61
|
+
path: string
|
|
62
|
+
absolutePath: string
|
|
63
|
+
id: string
|
|
64
|
+
markdown: string
|
|
65
|
+
frontmatter: Record<string, string | string[] | boolean>
|
|
66
|
+
sections: string[]
|
|
67
|
+
narrativeHash?: string
|
|
68
|
+
outputPath?: string
|
|
69
|
+
slides: DeckPlanSlideProjection[]
|
|
70
|
+
graphNodes: Array<{ id: string; type: WorkspaceGraphNodeType; file: string }>
|
|
71
|
+
graphRelations: VaultRelation[]
|
|
72
|
+
diagnostics: DeckPlanProjectionDiagnostic[]
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface DeckPlanSlideProjection {
|
|
76
|
+
path: string
|
|
77
|
+
absolutePath: string
|
|
78
|
+
id: string
|
|
79
|
+
slideIndex?: number
|
|
80
|
+
title: string
|
|
81
|
+
chapter: string
|
|
82
|
+
layout: string
|
|
83
|
+
components: string[]
|
|
84
|
+
structural: boolean
|
|
85
|
+
narrativeRole: string
|
|
86
|
+
markdown: string
|
|
87
|
+
frontmatter: Record<string, string | string[] | boolean>
|
|
88
|
+
sections: string[]
|
|
89
|
+
links: DeckPlanNarrativeLink[]
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface DeckPlanNarrativeLink {
|
|
93
|
+
id: string
|
|
94
|
+
relation: "uses_claim" | "uses_evidence" | "addresses_risk" | "answers_objection" | "mentions_gap"
|
|
95
|
+
group: string
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface DeckPlanProjectionDiagnostic {
|
|
99
|
+
severity: "warning" | "error"
|
|
100
|
+
code: string
|
|
101
|
+
message: string
|
|
102
|
+
file?: string
|
|
103
|
+
nodeId?: string
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const REQUIRED_DECK_PLAN_SECTIONS = [
|
|
107
|
+
"Source Authority",
|
|
108
|
+
"Audience / Goal / Decision",
|
|
109
|
+
"Deck Parameters",
|
|
110
|
+
"Chapter Map",
|
|
111
|
+
"Slide Plan",
|
|
112
|
+
"Evidence Trace",
|
|
113
|
+
"Boundary / Risk Treatment",
|
|
114
|
+
"Chapter Writing Batches",
|
|
115
|
+
"HTML Identity Contract",
|
|
116
|
+
]
|
|
117
|
+
|
|
118
|
+
export function writeDeckPlanArtifact(workspaceRoot: string, input: DeckPlanArtifactInput): { path: string; absolutePath: string } {
|
|
119
|
+
const absolutePath = join(workspaceRoot, DECK_PLAN_ARTIFACT_PATH)
|
|
120
|
+
mkdirSync(dirname(absolutePath), { recursive: true })
|
|
121
|
+
writeFileSync(absolutePath, renderDeckPlanMarkdown(input), "utf-8")
|
|
122
|
+
return { path: DECK_PLAN_ARTIFACT_PATH, absolutePath }
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export function readDeckPlanArtifact(workspaceRoot: string, expected?: { narrativeHash?: string; knownNodeIds?: Set<string> }): DeckPlanReadResult {
|
|
126
|
+
const projection = readDeckPlanProjection(workspaceRoot, expected)
|
|
127
|
+
const absolutePath = projection?.absolutePath ?? join(workspaceRoot, DECK_PLAN_INDEX_PATH)
|
|
128
|
+
if (!existsSync(absolutePath)) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
path: DECK_PLAN_INDEX_PATH,
|
|
132
|
+
absolutePath,
|
|
133
|
+
approvalStatus: "missing",
|
|
134
|
+
sections: [],
|
|
135
|
+
missingSections: REQUIRED_DECK_PLAN_SECTIONS,
|
|
136
|
+
warnings: [],
|
|
137
|
+
reason: `Deck plan file is missing: ${DECK_PLAN_INDEX_PATH}. Write the LLM-authored deck-plan/ projection before HTML generation.`,
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
const markdown = projection?.markdown ?? readFileSync(absolutePath, "utf-8")
|
|
141
|
+
const planHash = deckPlanBodyHash(markdown)
|
|
142
|
+
const approval = parseDeckPlanApproval(markdown)
|
|
143
|
+
const sections = projection?.sections ?? parseMarkdownSections(markdown)
|
|
144
|
+
const missingSections = REQUIRED_DECK_PLAN_SECTIONS.filter((section) => !sections.includes(section))
|
|
145
|
+
const warnings: string[] = projection?.diagnostics.map((diagnostic) => diagnostic.message) ?? []
|
|
146
|
+
if (missingSections.length > 0) warnings.push(`Missing required deck-plan sections: ${missingSections.join(", ")}.`)
|
|
147
|
+
let approvalStatus: DeckPlanReadResult["approvalStatus"] = "missing"
|
|
148
|
+
if (approval) {
|
|
149
|
+
approvalStatus = approval.status === "approved" ? "approved" : "pending"
|
|
150
|
+
if (expected?.narrativeHash && approval.narrativeHash && approval.narrativeHash !== expected.narrativeHash) {
|
|
151
|
+
approvalStatus = "stale"
|
|
152
|
+
warnings.push("Approval narrativeHash does not match current narrative state.")
|
|
153
|
+
}
|
|
154
|
+
if (approval.planHash && !isPlaceholderPlanHash(approval.planHash) && approval.planHash !== planHash) {
|
|
155
|
+
approvalStatus = "stale"
|
|
156
|
+
warnings.push("Legacy approval planHash does not match the current deck-plan body.")
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
approvalStatus = "missing"
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
ok: true,
|
|
163
|
+
path: projection?.path ?? DECK_PLAN_INDEX_PATH,
|
|
164
|
+
absolutePath,
|
|
165
|
+
markdown,
|
|
166
|
+
planHash,
|
|
167
|
+
approval,
|
|
168
|
+
approvalStatus,
|
|
169
|
+
sections,
|
|
170
|
+
missingSections,
|
|
171
|
+
warnings,
|
|
172
|
+
projection,
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function readDeckPlanProjection(workspaceRoot: string, expected?: { narrativeHash?: string; knownNodeIds?: Set<string> }): DeckPlanProjection | undefined {
|
|
177
|
+
const root = join(workspaceRoot, DECK_PLAN_DIR)
|
|
178
|
+
const indexPath = join(workspaceRoot, DECK_PLAN_INDEX_PATH)
|
|
179
|
+
const legacyPath = join(workspaceRoot, LEGACY_DECK_PLAN_ARTIFACT_PATH)
|
|
180
|
+
const absolutePath = existsSync(indexPath) ? indexPath : existsSync(legacyPath) ? legacyPath : ""
|
|
181
|
+
if (!absolutePath) return undefined
|
|
182
|
+
const markdown = readFileSync(absolutePath, "utf-8")
|
|
183
|
+
const parsed = parseVaultFrontmatter(markdown)
|
|
184
|
+
const split = splitMarkdownSections(parsed.body)
|
|
185
|
+
const sections = parseMarkdownSections(markdown)
|
|
186
|
+
const path = relativePath(workspaceRoot, absolutePath)
|
|
187
|
+
const id = stringField(parsed.frontmatter, "id") || "deck-plan"
|
|
188
|
+
const slides = existsSync(join(root, "slides")) ? readDeckPlanSlideFiles(workspaceRoot, expected?.knownNodeIds) : []
|
|
189
|
+
const diagnostics: DeckPlanProjectionDiagnostic[] = []
|
|
190
|
+
const narrativeHash = stringField(parsed.frontmatter, "narrativeHash") || narrativeHashFromMarkdown(markdown)
|
|
191
|
+
if (expected?.narrativeHash && narrativeHash && narrativeHash !== expected.narrativeHash) diagnostics.push({ severity: "warning", code: "stale_narrative_hash", message: "Deck plan narrativeHash does not match current narrative state.", file: path, nodeId: id })
|
|
192
|
+
if (expected?.narrativeHash && !narrativeHash) diagnostics.push({ severity: "warning", code: "missing_narrative_hash", message: "Deck plan index is missing narrativeHash; stale plan detection is limited.", file: path, nodeId: id })
|
|
193
|
+
diagnostics.push(...deckPlanIndexDiagnostics(slides))
|
|
194
|
+
diagnostics.push(...slides.flatMap((slide) => slideDiagnostics(slide, expected?.knownNodeIds)))
|
|
195
|
+
const graphNodes = [
|
|
196
|
+
{ id, type: "deck-plan" as const, file: path },
|
|
197
|
+
...slides.map((slide) => ({ id: slide.id, type: "deck-plan-slide" as const, file: slide.path })),
|
|
198
|
+
]
|
|
199
|
+
const graphRelations = slides.flatMap((slide) => slide.links.map((link) => ({
|
|
200
|
+
id: stableVaultRelationId(slide.id, link.relation, link.id),
|
|
201
|
+
fromId: slide.id,
|
|
202
|
+
relation: link.relation,
|
|
203
|
+
toId: link.id,
|
|
204
|
+
file: slide.path,
|
|
205
|
+
source: "inline" as const,
|
|
206
|
+
})))
|
|
207
|
+
return {
|
|
208
|
+
path,
|
|
209
|
+
absolutePath,
|
|
210
|
+
id,
|
|
211
|
+
markdown,
|
|
212
|
+
frontmatter: parsed.frontmatter,
|
|
213
|
+
sections,
|
|
214
|
+
narrativeHash,
|
|
215
|
+
outputPath: stringField(parsed.frontmatter, "outputPath"),
|
|
216
|
+
slides,
|
|
217
|
+
graphNodes,
|
|
218
|
+
graphRelations,
|
|
219
|
+
diagnostics,
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
export function validateDeckPlanApprovalFile(workspaceRoot: string, expected: { narrativeHash: string; planHash?: string }): DeckPlanApprovalValidation {
|
|
224
|
+
const read = readDeckPlanArtifact(workspaceRoot, { narrativeHash: expected.narrativeHash })
|
|
225
|
+
if (!read.ok || !read.markdown) return { ok: false, reason: read.reason, sections: read.sections, missingSections: read.missingSections }
|
|
226
|
+
return validateDeckPlanApproval(read.markdown, expected)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export function validateDeckPlanApproval(markdown: string, expected: { narrativeHash: string; planHash?: string }): DeckPlanApprovalValidation {
|
|
230
|
+
const approval = parseDeckPlanApproval(markdown)
|
|
231
|
+
const planHash = deckPlanBodyHash(markdown)
|
|
232
|
+
const sections = parseMarkdownSections(markdown)
|
|
233
|
+
const missingSections = REQUIRED_DECK_PLAN_SECTIONS.filter((section) => !sections.includes(section))
|
|
234
|
+
if (!approval) return { ok: false, reason: "Deck plan approval block is missing or malformed." }
|
|
235
|
+
if (approval.status !== "approved") return { ok: false, approval, reason: "Legacy deck plan approval is not approved." }
|
|
236
|
+
if (!approval.approvedBy) return { ok: false, approval, reason: "Deck plan approval requires approvedBy." }
|
|
237
|
+
if (!approval.approvedAt) return { ok: false, approval, reason: "Deck plan approval requires approvedAt." }
|
|
238
|
+
if (Number.isNaN(Date.parse(approval.approvedAt))) return { ok: false, approval, reason: "Deck plan approval approvedAt must be a parseable date/time." }
|
|
239
|
+
if (missingSections.length > 0) return { ok: false, approval, planHash, sections, missingSections, reason: `Deck plan is missing required sections: ${missingSections.join(", ")}.` }
|
|
240
|
+
if (approval.narrativeHash !== expected.narrativeHash) return { ok: false, approval, reason: "Deck plan approval is stale because narrativeHash does not match current narrative state." }
|
|
241
|
+
if (expected.planHash && approval.planHash !== expected.planHash) return { ok: false, approval, planHash, reason: "Deck plan approval is stale because planHash does not match the expected deck plan." }
|
|
242
|
+
if (approval.planHash && !isPlaceholderPlanHash(approval.planHash) && approval.planHash !== planHash) return { ok: false, approval, planHash, reason: "Legacy deck plan approval is stale because planHash does not match the current deck-plan body." }
|
|
243
|
+
return { ok: true, approval, planHash, sections, missingSections }
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function deckPlanBodyHash(markdown: string): string {
|
|
247
|
+
return createHash("sha1").update(stripApprovalSection(markdown).trim()).digest("hex")
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string>): DeckPlanSlideProjection[] {
|
|
251
|
+
const slidesDir = join(workspaceRoot, DECK_PLAN_SLIDES_DIR)
|
|
252
|
+
if (!existsSync(slidesDir) || !statSync(slidesDir).isDirectory()) return []
|
|
253
|
+
const slides: DeckPlanSlideProjection[] = []
|
|
254
|
+
for (const entry of readdirSync(slidesDir).sort()) {
|
|
255
|
+
const absolutePath = join(slidesDir, entry)
|
|
256
|
+
if (!entry.endsWith(".md") || !statSync(absolutePath).isFile()) continue
|
|
257
|
+
const markdown = readFileSync(absolutePath, "utf-8")
|
|
258
|
+
const parsed = parseVaultFrontmatter(markdown)
|
|
259
|
+
const split = splitMarkdownSections(parsed.body)
|
|
260
|
+
const path = relativePath(workspaceRoot, absolutePath)
|
|
261
|
+
const id = stringField(parsed.frontmatter, "id") || fileId(entry)
|
|
262
|
+
const links = parseDeckPlanNarrativeLinks(split.sections["narrative-links"] ?? parsed.body, knownNodeIds)
|
|
263
|
+
slides.push({
|
|
264
|
+
path,
|
|
265
|
+
absolutePath,
|
|
266
|
+
id,
|
|
267
|
+
slideIndex: numberField(parsed.frontmatter, "slideIndex"),
|
|
268
|
+
title: stringField(parsed.frontmatter, "title") || id,
|
|
269
|
+
chapter: stringField(parsed.frontmatter, "chapter"),
|
|
270
|
+
layout: stringField(parsed.frontmatter, "layout"),
|
|
271
|
+
components: arrayField(parsed.frontmatter, "components"),
|
|
272
|
+
structural: booleanField(parsed.frontmatter, "structural", false),
|
|
273
|
+
narrativeRole: stringField(parsed.frontmatter, "narrativeRole"),
|
|
274
|
+
markdown,
|
|
275
|
+
frontmatter: parsed.frontmatter,
|
|
276
|
+
sections: parseMarkdownSections(markdown),
|
|
277
|
+
links,
|
|
278
|
+
})
|
|
279
|
+
}
|
|
280
|
+
return slides.sort((a, b) => (a.slideIndex ?? Number.MAX_SAFE_INTEGER) - (b.slideIndex ?? Number.MAX_SAFE_INTEGER) || a.path.localeCompare(b.path))
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function parseDeckPlanNarrativeLinks(section: string, knownNodeIds?: Set<string>): DeckPlanNarrativeLink[] {
|
|
284
|
+
const links: DeckPlanNarrativeLink[] = []
|
|
285
|
+
let group = ""
|
|
286
|
+
for (const rawLine of section.replace(/\r\n/g, "\n").split("\n")) {
|
|
287
|
+
const heading = /^\s*([A-Za-z][A-Za-z\s/-]*):\s*$/.exec(rawLine)
|
|
288
|
+
if (heading) {
|
|
289
|
+
group = heading[1].trim().toLowerCase()
|
|
290
|
+
continue
|
|
291
|
+
}
|
|
292
|
+
for (const match of rawLine.matchAll(/\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/g)) {
|
|
293
|
+
const id = match[1].trim()
|
|
294
|
+
const relation = relationForDeckPlanLink(group, id)
|
|
295
|
+
links.push({ id, relation, group: group || inferredLinkGroup(id, knownNodeIds) })
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
return uniqueLinks(links)
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function relationForDeckPlanLink(group: string, id: string): DeckPlanNarrativeLink["relation"] {
|
|
302
|
+
const normalized = group.toLowerCase()
|
|
303
|
+
if (normalized.includes("evidence") || id.startsWith("evidence")) return "uses_evidence"
|
|
304
|
+
if (normalized.includes("risk") || id.startsWith("risk")) return "addresses_risk"
|
|
305
|
+
if (normalized.includes("objection") || id.startsWith("objection")) return "answers_objection"
|
|
306
|
+
if (normalized.includes("gap") || id.startsWith("gap") || id.startsWith("research-gap")) return "mentions_gap"
|
|
307
|
+
return "uses_claim"
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
function inferredLinkGroup(id: string, knownNodeIds?: Set<string>): string {
|
|
311
|
+
if (id.startsWith("evidence")) return "evidence"
|
|
312
|
+
if (id.startsWith("risk")) return "risk"
|
|
313
|
+
if (id.startsWith("objection")) return "objection"
|
|
314
|
+
if (id.startsWith("gap") || id.startsWith("research-gap")) return "gaps"
|
|
315
|
+
if (knownNodeIds?.has(id)) return "claims"
|
|
316
|
+
return "unknown"
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function deckPlanIndexDiagnostics(slides: DeckPlanSlideProjection[]): DeckPlanProjectionDiagnostic[] {
|
|
320
|
+
const diagnostics: DeckPlanProjectionDiagnostic[] = []
|
|
321
|
+
if (slides.length === 0) diagnostics.push({ severity: "warning", code: "deck_plan_slides_missing", message: "deck-plan/slides contains no slide plan Markdown files." })
|
|
322
|
+
const seen = new Map<number, DeckPlanSlideProjection>()
|
|
323
|
+
let previous = 0
|
|
324
|
+
for (const slide of slides) {
|
|
325
|
+
if (!slide.slideIndex || slide.slideIndex < 1) {
|
|
326
|
+
diagnostics.push({ severity: "warning", code: "slide_index_missing", message: `Deck-plan slide ${slide.id} is missing a positive 1-based slideIndex.`, file: slide.path, nodeId: slide.id })
|
|
327
|
+
continue
|
|
328
|
+
}
|
|
329
|
+
const duplicate = seen.get(slide.slideIndex)
|
|
330
|
+
if (duplicate) diagnostics.push({ severity: "warning", code: "slide_index_duplicate", message: `Deck-plan slideIndex ${slide.slideIndex} is duplicated by ${duplicate.id} and ${slide.id}.`, file: slide.path, nodeId: slide.id })
|
|
331
|
+
if (slide.slideIndex <= previous) diagnostics.push({ severity: "warning", code: "slide_index_order", message: `Deck-plan slide ${slide.id} is not in strictly increasing slideIndex order.`, file: slide.path, nodeId: slide.id })
|
|
332
|
+
previous = slide.slideIndex
|
|
333
|
+
seen.set(slide.slideIndex, slide)
|
|
334
|
+
}
|
|
335
|
+
return diagnostics
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function slideDiagnostics(slide: DeckPlanSlideProjection, knownNodeIds?: Set<string>): DeckPlanProjectionDiagnostic[] {
|
|
339
|
+
const diagnostics: DeckPlanProjectionDiagnostic[] = []
|
|
340
|
+
if (!slide.structural && !slide.links.some((link) => link.relation === "uses_claim")) diagnostics.push({ severity: "warning", code: "slide_claim_link_missing", message: `Non-structural deck-plan slide ${slide.id} has no claim wikilink in ## Narrative Links.`, file: slide.path, nodeId: slide.id })
|
|
341
|
+
if (knownNodeIds) {
|
|
342
|
+
for (const link of slide.links) {
|
|
343
|
+
if (!knownNodeIds.has(link.id)) diagnostics.push({ severity: "warning", code: "deck_plan_broken_link", message: `Deck-plan slide ${slide.id} links to unknown narrative node ${link.id}.`, file: slide.path, nodeId: slide.id })
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
return diagnostics
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function narrativeHashFromMarkdown(markdown: string): string {
|
|
350
|
+
const match = markdown.match(/narrativeHash:\s*`?([^`\s]+)`?/)
|
|
351
|
+
return match?.[1]?.trim() ?? ""
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
function relativePath(workspaceRoot: string, absolutePath: string): string {
|
|
355
|
+
return relative(workspaceRoot, absolutePath).replace(/\\/g, "/")
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
function stringField(frontmatter: Record<string, string | string[] | boolean>, key: string): string {
|
|
359
|
+
const value = frontmatter[key]
|
|
360
|
+
return typeof value === "string" ? value.trim() : ""
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function numberField(frontmatter: Record<string, string | string[] | boolean>, key: string): number | undefined {
|
|
364
|
+
const value = Number(stringField(frontmatter, key))
|
|
365
|
+
return Number.isFinite(value) ? value : undefined
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function booleanField(frontmatter: Record<string, string | string[] | boolean>, key: string, fallback: boolean): boolean {
|
|
369
|
+
const value = frontmatter[key]
|
|
370
|
+
if (typeof value === "boolean") return value
|
|
371
|
+
if (typeof value === "string" && (value === "true" || value === "false")) return value === "true"
|
|
372
|
+
return fallback
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function arrayField(frontmatter: Record<string, string | string[] | boolean>, key: string): string[] {
|
|
376
|
+
const value = frontmatter[key]
|
|
377
|
+
if (Array.isArray(value)) return value.map((item) => item.trim()).filter(Boolean)
|
|
378
|
+
if (typeof value === "string" && value.trim()) return value.split(",").map((item) => item.trim()).filter(Boolean)
|
|
379
|
+
return []
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function titleFromSectionKey(key: string): string {
|
|
383
|
+
return key.split("-").map((part) => part ? `${part[0].toUpperCase()}${part.slice(1)}` : part).join(" ")
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function fileId(file: string): string {
|
|
387
|
+
return file.replace(/\.md$/i, "")
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function uniqueLinks(links: DeckPlanNarrativeLink[]): DeckPlanNarrativeLink[] {
|
|
391
|
+
const seen = new Set<string>()
|
|
392
|
+
return links.filter((link) => {
|
|
393
|
+
const key = `${link.relation}:${link.id}`
|
|
394
|
+
if (seen.has(key)) return false
|
|
395
|
+
seen.add(key)
|
|
396
|
+
return true
|
|
397
|
+
})
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function renderDeckPlanMarkdown(input: DeckPlanArtifactInput): string {
|
|
401
|
+
const lines: string[] = []
|
|
402
|
+
lines.push("# Revela Deck Plan")
|
|
403
|
+
lines.push("")
|
|
404
|
+
lines.push("This file is the execution blueprint for HTML deck generation. Canonical meaning remains in `revela-narrative/`; `DECKS.json` remains compatibility/render state and cached projection data, not the HTML slide-count authority.")
|
|
405
|
+
lines.push("")
|
|
406
|
+
lines.push("## Plan Metadata")
|
|
407
|
+
lines.push("")
|
|
408
|
+
lines.push(`- Deck slug: \`${input.deck.slug}\``)
|
|
409
|
+
lines.push(`- Output path: \`${input.deck.outputPath}\``)
|
|
410
|
+
lines.push(`- Compiled at: \`${input.compiledAt}\``)
|
|
411
|
+
lines.push(`- Narrative hash: \`${input.narrativeHash}\``)
|
|
412
|
+
lines.push(`- Plan hash: \`${input.planHash}\``)
|
|
413
|
+
lines.push(`- Slide count: ${input.deck.slides.length}`)
|
|
414
|
+
lines.push("")
|
|
415
|
+
if (input.renderPlan) {
|
|
416
|
+
lines.push("## Source Authority")
|
|
417
|
+
lines.push("")
|
|
418
|
+
lines.push(`- Meaning: ${input.renderPlan.sourceAuthority.meaning}`)
|
|
419
|
+
lines.push(`- Render plan: ${input.renderPlan.sourceAuthority.renderPlan}`)
|
|
420
|
+
lines.push(`- State: ${input.renderPlan.sourceAuthority.state}`)
|
|
421
|
+
lines.push(`- HTML identity: ${input.renderPlan.sourceAuthority.htmlIdentity}`)
|
|
422
|
+
lines.push("")
|
|
423
|
+
lines.push("## Render Rules")
|
|
424
|
+
lines.push("")
|
|
425
|
+
for (const rule of input.renderPlan.renderRules) lines.push(`- ${rule}`)
|
|
426
|
+
lines.push("")
|
|
427
|
+
lines.push("## Chapter Requirements")
|
|
428
|
+
lines.push("")
|
|
429
|
+
for (const requirement of input.renderPlan.chapterRequirements) {
|
|
430
|
+
lines.push(`- ${requirement.title}: required substance slides ${requirement.requiredSubstanceSlides}, actual substance slides ${requirement.actualSubstanceSlides}; structural slides allowed: ${requirement.allowedStructuralSlides.join(", ") || "none"}.`)
|
|
431
|
+
}
|
|
432
|
+
lines.push("")
|
|
433
|
+
}
|
|
434
|
+
lines.push("## Deck Contract")
|
|
435
|
+
lines.push("")
|
|
436
|
+
lines.push("- Write one `<section class=\"slide\" data-slide-index=\"N\">` per planned slide in the completed deck, using positive 1-based slide indexes that are unique and strictly increase in DOM order. Partial chapter-by-chapter drafts may contain only the written prefix/range.")
|
|
437
|
+
lines.push("- Keep every rendered slide exactly 1920x1080px with no page-level scrollbars or hidden overflow.")
|
|
438
|
+
lines.push("- Preserve claim-led chapters, visual intent, evidence ids, source trace, supported scope, unsupported scope, caveats, and strength.")
|
|
439
|
+
lines.push("- Generate HTML chapter by chapter; do not draft a full 5+ slide deck in one broad write or patch.")
|
|
440
|
+
lines.push("")
|
|
441
|
+
lines.push("## Chapter Map")
|
|
442
|
+
lines.push("")
|
|
443
|
+
for (const chapter of input.chapters) {
|
|
444
|
+
lines.push(`- ${chapter.title} (${chapter.role}): slides ${formatSlideRange(chapter.slideIndexes)}${chapter.sourceClaimId ? `; claim ${chapter.sourceClaimId}` : ""}`)
|
|
445
|
+
}
|
|
446
|
+
lines.push("")
|
|
447
|
+
lines.push("## Slide Plan")
|
|
448
|
+
lines.push("")
|
|
449
|
+
for (const slide of input.deck.slides) lines.push(renderSlidePlan(slide, input.renderPlan?.slideRenderMetadata.find((item) => item.index === slide.index)))
|
|
450
|
+
if (input.renderPlan) {
|
|
451
|
+
lines.push("## Slide Render Metadata")
|
|
452
|
+
lines.push("")
|
|
453
|
+
for (const slide of input.renderPlan.slideRenderMetadata) lines.push(renderSlideMetadata(slide))
|
|
454
|
+
}
|
|
455
|
+
lines.push("## Chapter Writing Batches")
|
|
456
|
+
lines.push("")
|
|
457
|
+
lines.push("Use these batches for HTML generation. Keep the HTML valid after every batch and preserve previously written slides.")
|
|
458
|
+
lines.push("")
|
|
459
|
+
if (input.renderPlan) {
|
|
460
|
+
for (const batch of input.renderPlan.chapterWritingBatches) lines.push(`- ${batch.label}: ${batch.chapterTitle}, slides ${formatSlideRange(batch.slideIndexes)}. ${batch.instructions}`)
|
|
461
|
+
} else {
|
|
462
|
+
input.chapters.forEach((chapter, index) => {
|
|
463
|
+
const prefix = index === 0 ? "Initial shell and first chapter" : `Chapter batch ${index + 1}`
|
|
464
|
+
lines.push(`- ${prefix}: ${chapter.title}, slides ${formatSlideRange(chapter.slideIndexes)}.`)
|
|
465
|
+
})
|
|
466
|
+
}
|
|
467
|
+
if (input.renderPlan) {
|
|
468
|
+
lines.push("")
|
|
469
|
+
lines.push("## HTML Identity Contract")
|
|
470
|
+
lines.push("")
|
|
471
|
+
for (const rule of input.renderPlan.htmlIdentityContract) lines.push(`- ${rule}`)
|
|
472
|
+
}
|
|
473
|
+
lines.push("")
|
|
474
|
+
lines.push("## Quality Checks")
|
|
475
|
+
lines.push("")
|
|
476
|
+
for (const check of input.qualityChecks) lines.push(`- ${check.status}: ${check.id} - ${check.message}`)
|
|
477
|
+
lines.push("")
|
|
478
|
+
lines.push("## Approval")
|
|
479
|
+
lines.push("")
|
|
480
|
+
lines.push("Edit this block to approve the deck plan. Keep `planHash` and `narrativeHash` unchanged.")
|
|
481
|
+
lines.push("")
|
|
482
|
+
lines.push("```yaml")
|
|
483
|
+
lines.push("status: pending")
|
|
484
|
+
lines.push("approvedBy:")
|
|
485
|
+
lines.push("approvedAt:")
|
|
486
|
+
lines.push("approvalNote:")
|
|
487
|
+
lines.push(`planHash: ${input.planHash}`)
|
|
488
|
+
lines.push(`narrativeHash: ${input.narrativeHash}`)
|
|
489
|
+
lines.push("```")
|
|
490
|
+
lines.push("")
|
|
491
|
+
return `${lines.join("\n")}\n`
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function renderSlidePlan(slide: SlideSpec, metadata?: RenderPlanSlideMetadata): string {
|
|
495
|
+
const lines: string[] = []
|
|
496
|
+
const contentData = slide.content?.data as { visualIntent?: { kind?: string; component?: string; rationale?: string } } | undefined
|
|
497
|
+
const visualIntent = contentData?.visualIntent
|
|
498
|
+
lines.push(`### Slide ${slide.index}: ${slide.title}`)
|
|
499
|
+
lines.push("")
|
|
500
|
+
lines.push(`- Purpose: ${slide.purpose}`)
|
|
501
|
+
lines.push(`- Role: ${slide.narrativeRole}`)
|
|
502
|
+
if (metadata) {
|
|
503
|
+
lines.push(`- Slide kind: ${metadata.slideKind}`)
|
|
504
|
+
lines.push(`- Structural: ${metadata.structural ? "yes" : "no"}`)
|
|
505
|
+
lines.push(`- Counts toward claim substance: ${metadata.countsTowardClaimSubstance ? "yes" : "no"}`)
|
|
506
|
+
lines.push(`- Chapter requirement: ${metadata.claimChapterRequirement ?? "none"}`)
|
|
507
|
+
lines.push(`- Evidence trace required: ${metadata.evidenceTraceRequired ? "yes" : "no"}`)
|
|
508
|
+
}
|
|
509
|
+
lines.push(`- Layout: ${slide.layout}`)
|
|
510
|
+
lines.push(`- Components: ${(slide.components ?? []).join(", ") || "none"}`)
|
|
511
|
+
lines.push(`- Claim refs: ${(slide.claimRefs ?? []).map((ref) => `${ref.claimId} (${ref.role})`).join(", ") || (slide.claimIds ?? []).join(", ") || "none"}`)
|
|
512
|
+
lines.push(`- Evidence bindings: ${(slide.evidenceBindingIds ?? []).join(", ") || "none"}`)
|
|
513
|
+
lines.push(`- Visual intent: ${visualIntent?.kind ?? "not specified"}${visualIntent?.component ? ` via ${visualIntent.component}` : ""}${visualIntent?.rationale ? ` - ${visualIntent.rationale}` : ""}`)
|
|
514
|
+
lines.push(`- Visual brief: ${(slide.visuals ?? []).map((visual) => visual.brief).join(" | ") || "none"}`)
|
|
515
|
+
lines.push(`- Evidence trace: ${renderEvidenceTrace(slide)}`)
|
|
516
|
+
lines.push("")
|
|
517
|
+
return lines.join("\n")
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function renderSlideMetadata(slide: RenderPlanSlideMetadata): string {
|
|
521
|
+
const lines: string[] = []
|
|
522
|
+
lines.push(`- Slide ${slide.index}: ${slide.slideKind}; structural: ${slide.structural ? "yes" : "no"}; counts toward claim substance: ${slide.countsTowardClaimSubstance ? "yes" : "no"}; chapter: ${slide.chapterTitle ?? "none"}; requirement: ${slide.claimChapterRequirement ?? "none"}; components: ${slide.requiredComponents.join(", ") || "none"}; evidence trace required: ${slide.evidenceTraceRequired ? "yes" : "no"}.`)
|
|
523
|
+
return lines.join("\n")
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
function renderEvidenceTrace(slide: SlideSpec): string {
|
|
527
|
+
if (!slide.evidence || slide.evidence.length === 0) return "none"
|
|
528
|
+
return slide.evidence.map((item) => {
|
|
529
|
+
const source = item.source || item.sourcePath || item.findingsFile || item.url || "source unspecified"
|
|
530
|
+
const detail = [item.quote, item.location || item.page, item.caveat].filter(Boolean).join("; ")
|
|
531
|
+
return detail ? `${source} (${detail})` : source
|
|
532
|
+
}).join(" | ")
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function parseDeckPlanApproval(markdown: string): DeckPlanApproval | undefined {
|
|
536
|
+
const heading = markdown.match(/^## Approval\s*$/m)
|
|
537
|
+
if (!heading?.index && heading?.index !== 0) return undefined
|
|
538
|
+
const section = markdown.slice(heading.index)
|
|
539
|
+
const block = section.match(/```ya?ml\s*\n([\s\S]*?)\n```/i)
|
|
540
|
+
if (!block) return undefined
|
|
541
|
+
const approval: DeckPlanApproval = {}
|
|
542
|
+
for (const rawLine of block[1].split(/\r?\n/)) {
|
|
543
|
+
const line = rawLine.trim()
|
|
544
|
+
if (!line || line.startsWith("#")) continue
|
|
545
|
+
const match = line.match(/^([A-Za-z][A-Za-z0-9_-]*):\s*(.*)$/)
|
|
546
|
+
if (!match) continue
|
|
547
|
+
const value = cleanYamlScalar(match[2])
|
|
548
|
+
if (match[1] === "status") approval.status = value
|
|
549
|
+
if (match[1] === "approvedBy") approval.approvedBy = value
|
|
550
|
+
if (match[1] === "approvedAt") approval.approvedAt = value
|
|
551
|
+
if (match[1] === "approvalNote") approval.approvalNote = value
|
|
552
|
+
if (match[1] === "planHash") approval.planHash = value
|
|
553
|
+
if (match[1] === "narrativeHash") approval.narrativeHash = value
|
|
554
|
+
}
|
|
555
|
+
return approval
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
function stripApprovalSection(markdown: string): string {
|
|
559
|
+
return markdown.replace(/^## Approval\s*$[\s\S]*$/m, "").trim()
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
function parseMarkdownSections(markdown: string): string[] {
|
|
563
|
+
const sections: string[] = []
|
|
564
|
+
for (const match of markdown.matchAll(/^##\s+(.+?)\s*$/gm)) sections.push(match[1].trim())
|
|
565
|
+
return sections
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function isPlaceholderPlanHash(value: string): boolean {
|
|
569
|
+
const normalized = value.trim().toLowerCase()
|
|
570
|
+
return normalized === "" || normalized === "pending" || normalized === "pending-deck-plan-md" || normalized === "computed-by-confirmdeckplan" || normalized === "computed-by-confirm-deck-plan"
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function cleanYamlScalar(value: string): string {
|
|
574
|
+
const trimmed = value.trim()
|
|
575
|
+
if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) return trimmed.slice(1, -1).trim()
|
|
576
|
+
return trimmed
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function formatSlideRange(indexes: number[]): string {
|
|
580
|
+
if (indexes.length === 0) return "none"
|
|
581
|
+
const sorted = [...indexes].sort((a, b) => a - b)
|
|
582
|
+
if (sorted.length === 1) return String(sorted[0])
|
|
583
|
+
return `${sorted[0]}-${sorted[sorted.length - 1]}`
|
|
584
|
+
}
|