@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.
@@ -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
+ }