@cyber-dash-tech/revela 0.17.24 → 0.18.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.
Files changed (34) hide show
  1. package/README.md +24 -25
  2. package/README.zh-CN.md +25 -26
  3. package/bin/revela.ts +2 -4
  4. package/lib/commands/help.ts +13 -13
  5. package/lib/commands/init.ts +24 -0
  6. package/lib/commands/png.ts +29 -0
  7. package/lib/commands/refine.ts +1 -1
  8. package/lib/commands/research.ts +24 -0
  9. package/lib/commands/review.ts +92 -14
  10. package/lib/decks-state.ts +7 -7
  11. package/lib/design/designs.ts +44 -0
  12. package/lib/narrative-state/deck-plan-artifact.ts +504 -99
  13. package/lib/narrative-state/render-plan.ts +13 -14
  14. package/lib/pdf/export.ts +84 -24
  15. package/lib/refine/server.ts +4 -3
  16. package/lib/runtime/index.ts +21 -14
  17. package/lib/runtime/review.ts +4 -104
  18. package/lib/workspace-state/render-targets.ts +2 -2
  19. package/lib/workspace-state/rendered-artifacts.ts +1 -1
  20. package/lib/workspace-state/types.ts +1 -1
  21. package/package.json +1 -1
  22. package/plugin.ts +31 -42
  23. package/plugins/revela/.codex-plugin/plugin.json +2 -2
  24. package/plugins/revela/.mcp.json +1 -1
  25. package/plugins/revela/mcp/revela-server.ts +58 -80
  26. package/plugins/revela/skills/revela-design/SKILL.md +4 -2
  27. package/plugins/revela/skills/revela-domain/SKILL.md +1 -1
  28. package/plugins/revela/skills/revela-export/SKILL.md +4 -5
  29. package/plugins/revela/skills/revela-init/SKILL.md +19 -34
  30. package/plugins/revela/skills/revela-make-deck/SKILL.md +15 -41
  31. package/plugins/revela/skills/revela-research/SKILL.md +17 -26
  32. package/plugins/revela/skills/revela-review-deck/SKILL.md +11 -29
  33. package/skill/SKILL.md +22 -19
  34. package/plugins/revela/skills/revela-story/SKILL.md +0 -24
@@ -9,10 +9,11 @@ import type { VaultRelation, WorkspaceGraphNodeType } from "../narrative-vault/t
9
9
  import type { DeckPlanChapter, DeckPlanQualityCheck, RenderPlanContract, RenderPlanSlideMetadata } from "./render-plan"
10
10
 
11
11
  export const DECK_PLAN_DIR = "deck-plan"
12
+ export const DECK_PLAN_MARKDOWN_PATH = "deck-plan.md"
12
13
  export const DECK_PLAN_INDEX_PATH = "deck-plan/index.md"
13
14
  export const DECK_PLAN_SLIDES_DIR = "deck-plan/slides"
14
15
  export const LEGACY_DECK_PLAN_ARTIFACT_PATH = "decks/deck-plan.md"
15
- export const DECK_PLAN_ARTIFACT_PATH = DECK_PLAN_INDEX_PATH
16
+ export const DECK_PLAN_ARTIFACT_PATH = DECK_PLAN_MARKDOWN_PATH
16
17
 
17
18
  export interface DeckPlanArtifactInput {
18
19
  deck: DeckSpec
@@ -65,6 +66,7 @@ export interface DeckPlanProjection {
65
66
  frontmatter: Record<string, string | string[] | boolean>
66
67
  sections: string[]
67
68
  narrativeHash?: string
69
+ designName?: string
68
70
  outputPath?: string
69
71
  slides: DeckPlanSlideProjection[]
70
72
  graphNodes: Array<{ id: string; type: WorkspaceGraphNodeType; file: string }>
@@ -88,6 +90,8 @@ export interface DeckPlanSlideProjection {
88
90
  frontmatter: Record<string, string | string[] | boolean>
89
91
  sections: string[]
90
92
  links: DeckPlanNarrativeLink[]
93
+ sourceLinks: DeckPlanSourceLinks
94
+ caveats: string[]
91
95
  }
92
96
 
93
97
  export interface DeckPlanSlideComponentPlan {
@@ -101,6 +105,7 @@ export interface DeckPlanSlideComponentPlan {
101
105
  sourceNotes: string[]
102
106
  renderNotes: string[]
103
107
  placementNote?: string
108
+ children?: DeckPlanSlideComponentPlan[]
104
109
  }
105
110
 
106
111
  export interface DeckPlanSlideUpsertComponentInput {
@@ -114,9 +119,20 @@ export interface DeckPlanSlideUpsertComponentInput {
114
119
  sourceNotes?: string[]
115
120
  renderNotes?: string[]
116
121
  placementNote?: string
122
+ children?: DeckPlanSlideUpsertComponentInput[]
123
+ }
124
+
125
+ export interface DeckPlanSourceLinks {
126
+ materials: string[]
127
+ findings: string[]
128
+ assets: string[]
129
+ urls: string[]
130
+ caveats: string[]
117
131
  }
118
132
 
119
133
  export interface DeckPlanSlideUpsertInput {
134
+ designName?: string
135
+ outputPath?: string
120
136
  slideIndex: number
121
137
  id?: string
122
138
  title: string
@@ -131,7 +147,8 @@ export interface DeckPlanSlideUpsertInput {
131
147
  rationale?: string
132
148
  brief?: string
133
149
  } | string
134
- narrativeLinks: {
150
+ sourceLinks?: Partial<DeckPlanSourceLinks>
151
+ narrativeLinks?: {
135
152
  claimIds?: string[]
136
153
  evidenceIds?: string[]
137
154
  riskIds?: string[]
@@ -165,15 +182,14 @@ export interface DeckPlanProjectionDiagnostic {
165
182
  }
166
183
 
167
184
  export const REQUIRED_DECK_PLAN_SECTIONS = [
185
+ "Goal",
186
+ "Audience",
187
+ "Design",
168
188
  "Source Authority",
169
- "Audience / Goal / Decision",
170
- "Deck Parameters",
171
189
  "Chapter Map",
172
- "Slide Plan",
173
- "Evidence Trace",
174
- "Boundary / Risk Treatment",
175
- "Chapter Writing Batches",
176
- "HTML Identity Contract",
190
+ "Slides",
191
+ "Unresolved Inputs",
192
+ "HTML Contract",
177
193
  ]
178
194
 
179
195
  export function writeDeckPlanArtifact(workspaceRoot: string, input: DeckPlanArtifactInput): { path: string; absolutePath: string } {
@@ -185,17 +201,17 @@ export function writeDeckPlanArtifact(workspaceRoot: string, input: DeckPlanArti
185
201
 
186
202
  export function readDeckPlanArtifact(workspaceRoot: string, expected?: { narrativeHash?: string; knownNodeIds?: Set<string> }): DeckPlanReadResult {
187
203
  const projection = readDeckPlanProjection(workspaceRoot, expected)
188
- const absolutePath = projection?.absolutePath ?? join(workspaceRoot, DECK_PLAN_INDEX_PATH)
204
+ const absolutePath = projection?.absolutePath ?? join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH)
189
205
  if (!existsSync(absolutePath)) {
190
206
  return {
191
207
  ok: false,
192
- path: DECK_PLAN_INDEX_PATH,
208
+ path: DECK_PLAN_MARKDOWN_PATH,
193
209
  absolutePath,
194
210
  approvalStatus: "missing",
195
211
  sections: [],
196
212
  missingSections: REQUIRED_DECK_PLAN_SECTIONS,
197
213
  warnings: [],
198
- reason: `Deck plan file is missing: ${DECK_PLAN_INDEX_PATH}. Write the LLM-authored deck-plan/ projection before HTML generation.`,
214
+ reason: `Deck plan file is missing: ${DECK_PLAN_MARKDOWN_PATH}. Write the LLM-authored deck plan before HTML generation.`,
199
215
  }
200
216
  }
201
217
  const markdown = projection?.markdown ?? readFileSync(absolutePath, "utf-8")
@@ -221,7 +237,7 @@ export function readDeckPlanArtifact(workspaceRoot: string, expected?: { narrati
221
237
  }
222
238
  return {
223
239
  ok: true,
224
- path: projection?.path ?? DECK_PLAN_INDEX_PATH,
240
+ path: projection?.path ?? DECK_PLAN_MARKDOWN_PATH,
225
241
  absolutePath,
226
242
  markdown,
227
243
  planHash,
@@ -236,9 +252,10 @@ export function readDeckPlanArtifact(workspaceRoot: string, expected?: { narrati
236
252
 
237
253
  export function readDeckPlanProjection(workspaceRoot: string, expected?: { narrativeHash?: string; knownNodeIds?: Set<string> }): DeckPlanProjection | undefined {
238
254
  const root = join(workspaceRoot, DECK_PLAN_DIR)
255
+ const singlePath = join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH)
239
256
  const indexPath = join(workspaceRoot, DECK_PLAN_INDEX_PATH)
240
257
  const legacyPath = join(workspaceRoot, LEGACY_DECK_PLAN_ARTIFACT_PATH)
241
- const absolutePath = existsSync(indexPath) ? indexPath : existsSync(legacyPath) ? legacyPath : ""
258
+ const absolutePath = existsSync(singlePath) ? singlePath : existsSync(indexPath) ? indexPath : existsSync(legacyPath) ? legacyPath : ""
242
259
  if (!absolutePath) return undefined
243
260
  const markdown = readFileSync(absolutePath, "utf-8")
244
261
  const parsed = parseVaultFrontmatter(markdown)
@@ -246,7 +263,8 @@ export function readDeckPlanProjection(workspaceRoot: string, expected?: { narra
246
263
  const sections = parseMarkdownSections(markdown)
247
264
  const path = relativePath(workspaceRoot, absolutePath)
248
265
  const id = stringField(parsed.frontmatter, "id") || "deck-plan"
249
- const slides = existsSync(join(root, "slides")) ? readDeckPlanSlideFiles(workspaceRoot, expected?.knownNodeIds) : []
266
+ const isSingleFile = relativePath(workspaceRoot, absolutePath) === DECK_PLAN_MARKDOWN_PATH
267
+ const slides = isSingleFile ? readDeckPlanSlidesFromSingleFile(workspaceRoot, absolutePath, markdown, expected?.knownNodeIds) : existsSync(join(root, "slides")) ? readDeckPlanSlideFiles(workspaceRoot, expected?.knownNodeIds) : []
250
268
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
251
269
  const narrativeHash = stringField(parsed.frontmatter, "narrativeHash") || narrativeHashFromMarkdown(markdown)
252
270
  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 })
@@ -273,7 +291,8 @@ export function readDeckPlanProjection(workspaceRoot: string, expected?: { narra
273
291
  frontmatter: parsed.frontmatter,
274
292
  sections,
275
293
  narrativeHash,
276
- outputPath: stringField(parsed.frontmatter, "outputPath"),
294
+ designName: stringField(parsed.frontmatter, "designName") || stringField(parsed.frontmatter, "design"),
295
+ outputPath: stringField(parsed.frontmatter, "outputPath") || stringField(parsed.frontmatter, "output"),
277
296
  slides,
278
297
  graphNodes,
279
298
  graphRelations,
@@ -311,36 +330,209 @@ export function deckPlanBodyHash(markdown: string): string {
311
330
  export function upsertDeckPlanSlideArtifact(
312
331
  workspaceRoot: string,
313
332
  input: DeckPlanSlideUpsertInput,
314
- options: { narrativeHash?: string; knownNodeIds?: Set<string>; designLayouts: string[]; designComponents: string[] },
333
+ options: { narrativeHash?: string; knownNodeIds?: Set<string>; designLayouts: string[]; designComponents: string[]; layoutSlots?: Record<string, string[]>; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> },
315
334
  ): DeckPlanSlideUpsertResult {
316
335
  const diagnostics = validateDeckPlanSlideUpsert(input, options)
317
336
  if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) return { ok: false, diagnostics }
318
337
 
319
- mkdirSync(join(workspaceRoot, DECK_PLAN_SLIDES_DIR), { recursive: true })
320
- ensureDeckPlanIndex(workspaceRoot, options.narrativeHash)
321
-
322
338
  const existing = readDeckPlanProjection(workspaceRoot, { narrativeHash: options.narrativeHash, knownNodeIds: options.knownNodeIds })
323
339
  const existingSlide = existing?.slides.find((slide) => slide.slideIndex === input.slideIndex)
324
340
  const id = input.id?.trim() || existingSlide?.id || `slide-${slugify(input.title)}`
325
- const filename = `${String(input.slideIndex).padStart(3, "0")}-${slugify(input.title)}.md`
326
- const path = `${DECK_PLAN_SLIDES_DIR}/${filename}`
327
- const absolutePath = join(workspaceRoot, path)
328
- const markdown = renderDeckPlanSlideMarkdown({ ...input, id })
329
-
330
- writeFileSync(absolutePath, markdown, "utf-8")
331
- if (existingSlide && existingSlide.absolutePath !== absolutePath && existsSync(existingSlide.absolutePath)) {
332
- try {
333
- rmSync(existingSlide.absolutePath)
334
- } catch {
335
- // Empty stale files are ignored by readers only when removed; if removal fails,
336
- // duplicate slideIndex diagnostics will surface on the next read.
337
- }
338
- }
339
-
340
- updateDeckPlanIndex(workspaceRoot, options.narrativeHash)
341
+ const nextSlide = projectionFromSlideInput(workspaceRoot, { ...input, id }, existingSlide)
342
+ const slides = [...(existing?.slides.filter((slide) => slide.slideIndex !== input.slideIndex) ?? []), nextSlide]
343
+ .sort((a, b) => (a.slideIndex ?? Number.MAX_SAFE_INTEGER) - (b.slideIndex ?? Number.MAX_SAFE_INTEGER))
344
+ const designName = input.designName || existing?.designName
345
+ const outputPath = input.outputPath || existing?.outputPath
346
+ writeDeckPlanSingleFile(workspaceRoot, {
347
+ title: "Deck Plan",
348
+ designName,
349
+ outputPath,
350
+ slides,
351
+ })
341
352
  const projection = readDeckPlanProjection(workspaceRoot, { narrativeHash: options.narrativeHash, knownNodeIds: options.knownNodeIds })
342
353
  const slide = projection?.slides.find((item) => item.slideIndex === input.slideIndex)
343
- return { ok: true, path, absolutePath, updated: Boolean(existingSlide), slide, diagnostics: [...diagnostics, ...(projection?.diagnostics ?? [])] }
354
+ return { ok: true, path: DECK_PLAN_MARKDOWN_PATH, absolutePath: join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH), updated: Boolean(existingSlide), slide, diagnostics: [...diagnostics, ...(projection?.diagnostics ?? [])] }
355
+ }
356
+
357
+ function projectionFromSlideInput(workspaceRoot: string, input: DeckPlanSlideUpsertInput & { id: string }, existing?: DeckPlanSlideProjection): DeckPlanSlideProjection {
358
+ const sourceLinks = sourceLinksForInput(input)
359
+ const caveats = uniqueStrings([...(input.caveats ?? []), ...sourceLinks.caveats])
360
+ const componentPlan = input.components.map(componentInputToPlan)
361
+ const slide: DeckPlanSlideProjection = {
362
+ path: DECK_PLAN_MARKDOWN_PATH,
363
+ absolutePath: join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH),
364
+ id: input.id,
365
+ slideIndex: input.slideIndex,
366
+ title: input.title,
367
+ chapter: input.chapter,
368
+ layout: input.layout,
369
+ components: uniqueStrings(componentPlan.flatMap(flattenComponentNames)),
370
+ componentPlan,
371
+ structural: input.structural ?? false,
372
+ narrativeRole: input.narrativeRole,
373
+ markdown: "",
374
+ frontmatter: existing?.frontmatter ?? {},
375
+ sections: [],
376
+ links: sourceLinksToNarrativeLinks(sourceLinks, input.narrativeLinks ? sourceLinksToNarrativeLinks(sourceLinksFromNarrativeLinks(input.narrativeLinks)) : []),
377
+ sourceLinks,
378
+ caveats,
379
+ }
380
+ slide.markdown = renderDeckPlanSlideBlock(slide, input.visualIntent)
381
+ return slide
382
+ }
383
+
384
+ function componentInputToPlan(component: DeckPlanSlideUpsertComponentInput): DeckPlanSlideComponentPlan {
385
+ return normalizeComponentPlan({
386
+ name: component.name?.trim() || "",
387
+ slot: component.slot?.trim() || "",
388
+ position: component.position?.trim() || "",
389
+ purpose: component.purpose?.trim() || "",
390
+ content: component.content?.trim() || "",
391
+ claimIds: uniqueStrings(component.claimIds ?? []),
392
+ evidenceIds: uniqueStrings(component.evidenceIds ?? []),
393
+ sourceNotes: (component.sourceNotes ?? []).map((item) => item.trim()).filter(Boolean),
394
+ renderNotes: (component.renderNotes ?? []).map((item) => item.trim()).filter(Boolean),
395
+ placementNote: component.placementNote?.trim(),
396
+ children: component.children?.map(componentInputToPlan),
397
+ })
398
+ }
399
+
400
+ function flattenComponentNames(component: DeckPlanSlideComponentPlan): string[] {
401
+ return [component.name, ...(component.children ?? []).flatMap(flattenComponentNames)].filter(Boolean)
402
+ }
403
+
404
+ function writeDeckPlanSingleFile(workspaceRoot: string, input: {
405
+ title: string
406
+ goal?: string
407
+ audience?: string
408
+ designName?: string
409
+ outputPath?: string
410
+ sourceAuthority?: string[]
411
+ unresolvedInputs?: string[]
412
+ slides: DeckPlanSlideProjection[]
413
+ }): { path: string; absolutePath: string } {
414
+ const absolutePath = join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH)
415
+ const lines: string[] = []
416
+ lines.push("---")
417
+ lines.push("type: deck-plan")
418
+ lines.push("version: 0.18.1")
419
+ if (input.designName) lines.push(`designName: ${yamlScalar(input.designName)}`)
420
+ if (input.outputPath) lines.push(`outputPath: ${yamlScalar(input.outputPath)}`)
421
+ lines.push("---")
422
+ lines.push("")
423
+ lines.push(`# ${input.title || "Deck Plan"}`)
424
+ lines.push("")
425
+ lines.push("## Goal")
426
+ lines.push("")
427
+ lines.push(input.goal || "To be specified from user intent and source materials.")
428
+ lines.push("")
429
+ lines.push("## Audience")
430
+ lines.push("")
431
+ lines.push(input.audience || "To be specified.")
432
+ lines.push("")
433
+ lines.push("## Design")
434
+ lines.push("")
435
+ lines.push(`- Design: ${input.designName || "active design"}`)
436
+ if (input.outputPath) lines.push(`- Output path: ${input.outputPath}`)
437
+ lines.push("")
438
+ lines.push("## Source Authority")
439
+ lines.push("")
440
+ const sourceAuthority = input.sourceAuthority?.filter(Boolean) ?? ["Local materials, reviewed findings, workspace assets, explicit URLs, and user intent are the source context.", "Deck-plan is the render execution plan for HTML deck generation."]
441
+ for (const item of sourceAuthority) lines.push(`- ${item}`)
442
+ lines.push("")
443
+ lines.push("## Chapter Map")
444
+ lines.push("")
445
+ const chapterMap = new Map<string, number[]>()
446
+ for (const slide of input.slides) chapterMap.set(slide.chapter || "Unassigned", [...(chapterMap.get(slide.chapter || "Unassigned") ?? []), slide.slideIndex ?? 0].filter(Boolean))
447
+ for (const [chapter, indexes] of chapterMap) lines.push(`- ${chapter}: slides ${formatSlideRange(indexes)}`)
448
+ if (chapterMap.size === 0) lines.push("- No slides planned yet.")
449
+ lines.push("")
450
+ lines.push("## Slides")
451
+ lines.push("")
452
+ for (const slide of input.slides) {
453
+ lines.push(renderDeckPlanSlideBlock(slide))
454
+ lines.push("")
455
+ }
456
+ lines.push("## Unresolved Inputs")
457
+ lines.push("")
458
+ const unresolved = input.unresolvedInputs?.filter(Boolean) ?? []
459
+ if (unresolved.length === 0) lines.push("- None.")
460
+ else for (const item of unresolved) lines.push(`- ${item}`)
461
+ lines.push("")
462
+ lines.push("## HTML Contract")
463
+ lines.push("")
464
+ lines.push("- Render one `<section class=\"slide\" data-slide-index=\"N\">` per planned slide.")
465
+ lines.push("- Use positive 1-based slide indexes, unique indexes, DOM order, and one direct `.slide-canvas` child per slide.")
466
+ lines.push("")
467
+ writeFileSync(absolutePath, lines.join("\n"), "utf-8")
468
+ return { path: DECK_PLAN_MARKDOWN_PATH, absolutePath }
469
+ }
470
+
471
+ function renderDeckPlanSlideBlock(slide: DeckPlanSlideProjection, visualIntent?: DeckPlanSlideUpsertInput["visualIntent"]): string {
472
+ const lines: string[] = []
473
+ lines.push(`### Slide ${slide.slideIndex ?? "?"} — ${slide.title}`)
474
+ lines.push("")
475
+ lines.push(`- Id: ${slide.id}`)
476
+ lines.push(`- Chapter: ${slide.chapter || "Unassigned"}`)
477
+ lines.push(`- Role: ${slide.narrativeRole || "Not specified"}`)
478
+ lines.push(`- Structural: ${slide.structural ? "true" : "false"}`)
479
+ lines.push(`- Layout: ${slide.layout || "unspecified"}`)
480
+ lines.push(`- Components: ${slide.components.join(", ") || "none"}`)
481
+ lines.push("")
482
+ lines.push("#### Visual Intent")
483
+ lines.push("")
484
+ if (visualIntent) lines.push(renderVisualIntent(visualIntent))
485
+ else lines.push("- Brief: Not specified.")
486
+ lines.push("")
487
+ lines.push("#### Component Plan")
488
+ lines.push("")
489
+ for (const component of slide.componentPlan) lines.push(renderComponentPlanMarkdown(component, 5))
490
+ lines.push("#### Source Links")
491
+ lines.push("")
492
+ lines.push(renderSourceLinksMarkdown(slide.sourceLinks))
493
+ lines.push("")
494
+ lines.push("#### Caveats")
495
+ lines.push("")
496
+ if (slide.caveats.length === 0) lines.push("- None.")
497
+ else for (const caveat of slide.caveats) lines.push(`- ${caveat}`)
498
+ lines.push("")
499
+ return lines.join("\n")
500
+ }
501
+
502
+ function renderComponentPlanMarkdown(component: DeckPlanSlideComponentPlan, headingLevel: number): string {
503
+ const lines: string[] = []
504
+ lines.push(`${"#".repeat(headingLevel)} ${component.name}`)
505
+ lines.push("")
506
+ lines.push(`- Slot: ${component.slot}`)
507
+ lines.push(`- Position: ${component.position}`)
508
+ if (component.placementNote) lines.push(`- Placement note: ${component.placementNote}`)
509
+ lines.push(`- Purpose: ${component.purpose}`)
510
+ lines.push("- Content:")
511
+ lines.push(...indentMultiline(component.content))
512
+ lines.push(`- Claim ids: ${formatCsv(component.claimIds)}`)
513
+ lines.push(`- Evidence ids: ${formatCsv(component.evidenceIds)}`)
514
+ lines.push(`- Source notes: ${formatListValue(component.sourceNotes)}`)
515
+ lines.push(`- Render notes: ${formatListValue(component.renderNotes)}`)
516
+ lines.push("")
517
+ for (const child of component.children ?? []) lines.push(renderComponentPlanMarkdown(child, headingLevel + 1))
518
+ return lines.join("\n")
519
+ }
520
+
521
+ function renderSourceLinksMarkdown(sourceLinks: DeckPlanSourceLinks): string {
522
+ const lines: string[] = []
523
+ for (const [label, values] of [
524
+ ["Materials", sourceLinks.materials],
525
+ ["Findings", sourceLinks.findings],
526
+ ["Assets", sourceLinks.assets],
527
+ ["URLs", sourceLinks.urls],
528
+ ["Caveats", sourceLinks.caveats],
529
+ ] as const) {
530
+ lines.push(`${label}:`)
531
+ if (values.length === 0) lines.push("- None.")
532
+ else for (const value of values) lines.push(value.includes("/") && !/^https?:\/\//i.test(value) ? `- [[${value}]]` : `- ${value}`)
533
+ lines.push("")
534
+ }
535
+ return lines.join("\n").trim()
344
536
  }
345
537
 
346
538
  function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string>): DeckPlanSlideProjection[] {
@@ -356,7 +548,9 @@ function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string
356
548
  const path = relativePath(workspaceRoot, absolutePath)
357
549
  const id = stringField(parsed.frontmatter, "id") || fileId(entry)
358
550
  const componentPlan = parseDeckPlanComponentPlan(split.sections["component-plan"] ?? "")
359
- const links = parseDeckPlanNarrativeLinks(split.sections["narrative-links"] ?? parsed.body, knownNodeIds)
551
+ const sourceLinks = parseDeckPlanSourceLinks(split.sections["source-links"] ?? "")
552
+ const links = sourceLinksToNarrativeLinks(sourceLinks, parseDeckPlanNarrativeLinks(split.sections["narrative-links"] ?? parsed.body, knownNodeIds))
553
+ const caveats = parseBulletText(split.sections["caveats"] ?? "")
360
554
  slides.push({
361
555
  path,
362
556
  absolutePath,
@@ -373,11 +567,80 @@ function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string
373
567
  frontmatter: parsed.frontmatter,
374
568
  sections: parseMarkdownSections(markdown),
375
569
  links,
570
+ sourceLinks,
571
+ caveats,
376
572
  })
377
573
  }
378
574
  return slides.sort((a, b) => (a.slideIndex ?? Number.MAX_SAFE_INTEGER) - (b.slideIndex ?? Number.MAX_SAFE_INTEGER) || a.path.localeCompare(b.path))
379
575
  }
380
576
 
577
+ function readDeckPlanSlidesFromSingleFile(workspaceRoot: string, absolutePath: string, markdown: string, knownNodeIds?: Set<string>): DeckPlanSlideProjection[] {
578
+ const path = relativePath(workspaceRoot, absolutePath)
579
+ const body = parseVaultFrontmatter(markdown).body
580
+ const matches = [...body.matchAll(/^[ \t]*###\s+Slide\s+(\d+)\s+(?:—|-)\s+(.+?)\s*$/gm)]
581
+ const slides: DeckPlanSlideProjection[] = []
582
+ for (let i = 0; i < matches.length; i++) {
583
+ const match = matches[i]
584
+ const start = match.index ?? 0
585
+ const nextSlide = i + 1 < matches.length ? matches[i + 1].index ?? body.length : body.length
586
+ const headingEnd = body.indexOf("\n", start)
587
+ const searchStart = headingEnd === -1 ? start + match[0].length : headingEnd + 1
588
+ const nextSection = body.slice(searchStart).search(/^[ \t]*##\s+(?!#)/m)
589
+ const end = Math.min(nextSlide, nextSection === -1 ? body.length : searchStart + nextSection)
590
+ const block = body.slice(start, end).trim()
591
+ const slideIndex = Number(match[1])
592
+ const title = match[2].trim()
593
+ const fields = parseSlideBlockFields(block)
594
+ const id = fields.id || `slide-${slugify(title)}`
595
+ const sourceLinks = normalizeSourceLinks(parseDeckPlanSourceLinks(singleFileSubsection(block, "Source Links")))
596
+ const narrativeLinks = parseDeckPlanNarrativeLinks(singleFileSubsection(block, "Narrative Links") || block, knownNodeIds)
597
+ const links = sourceLinksToNarrativeLinks(sourceLinks, narrativeLinks)
598
+ const caveats = uniqueStrings([...parseBulletText(singleFileSubsection(block, "Caveats")), ...sourceLinks.caveats])
599
+ const componentPlan = parseDeckPlanComponentPlan(singleFileSubsection(block, "Component Plan"))
600
+ slides.push({
601
+ path,
602
+ absolutePath,
603
+ id,
604
+ slideIndex,
605
+ title,
606
+ chapter: fields.chapter || "",
607
+ layout: fields.layout || "",
608
+ components: parseCsv(fields.components || componentPlan.map((component) => component.name).join(", ")),
609
+ componentPlan,
610
+ structural: fields.structural === "true" || fields.structural === "yes",
611
+ narrativeRole: fields.role || fields.narrativeRole || "",
612
+ markdown: block,
613
+ frontmatter: {},
614
+ sections: parseMarkdownSections(block),
615
+ links,
616
+ sourceLinks,
617
+ caveats,
618
+ })
619
+ }
620
+ return slides.sort((a, b) => (a.slideIndex ?? Number.MAX_SAFE_INTEGER) - (b.slideIndex ?? Number.MAX_SAFE_INTEGER))
621
+ }
622
+
623
+ function parseSlideBlockFields(block: string): Record<string, string> {
624
+ const fields: Record<string, string> = {}
625
+ for (const rawLine of block.split(/\r?\n/)) {
626
+ const match = /^-\s+([A-Za-z][A-Za-z ]+):\s*(.*)$/.exec(rawLine.trim())
627
+ if (!match) continue
628
+ const key = match[1].trim().replace(/\s+/g, "")
629
+ fields[key[0].toLowerCase() + key.slice(1)] = cleanPlanValue(match[2])
630
+ }
631
+ return fields
632
+ }
633
+
634
+ function singleFileSubsection(block: string, heading: string): string {
635
+ const re = new RegExp(`^[ \\t]*####\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "mi")
636
+ const match = re.exec(block)
637
+ if (!match || match.index === undefined) return ""
638
+ const start = match.index + match[0].length
639
+ const rest = block.slice(start)
640
+ const next = rest.search(/^[ \t]*####\s+/m)
641
+ return (next === -1 ? rest : rest.slice(0, next)).trim()
642
+ }
643
+
381
644
  function parseDeckPlanNarrativeLinks(section: string, knownNodeIds?: Set<string>): DeckPlanNarrativeLink[] {
382
645
  const links: DeckPlanNarrativeLink[] = []
383
646
  let group = ""
@@ -396,8 +659,97 @@ function parseDeckPlanNarrativeLinks(section: string, knownNodeIds?: Set<string>
396
659
  return uniqueLinks(links)
397
660
  }
398
661
 
662
+ function emptySourceLinks(): DeckPlanSourceLinks {
663
+ return { materials: [], findings: [], assets: [], urls: [], caveats: [] }
664
+ }
665
+
666
+ function normalizeSourceLinks(input?: Partial<DeckPlanSourceLinks>): DeckPlanSourceLinks {
667
+ return {
668
+ materials: uniqueStrings(input?.materials ?? []),
669
+ findings: uniqueStrings(input?.findings ?? []),
670
+ assets: uniqueStrings(input?.assets ?? []),
671
+ urls: uniqueStrings(input?.urls ?? []),
672
+ caveats: uniqueStrings(input?.caveats ?? []),
673
+ }
674
+ }
675
+
676
+ function sourceLinksFromNarrativeLinks(input?: DeckPlanSlideUpsertInput["narrativeLinks"]): DeckPlanSourceLinks {
677
+ const links = emptySourceLinks()
678
+ for (const id of input?.evidenceIds ?? []) {
679
+ if (/^https?:\/\//i.test(id)) links.urls.push(id)
680
+ else if (id.startsWith("assets/")) links.assets.push(id)
681
+ else if (id.startsWith("researches/")) links.findings.push(id)
682
+ else if (id.startsWith("materials/") || id.startsWith("sources/")) links.materials.push(id)
683
+ else links.findings.push(id)
684
+ }
685
+ for (const id of input?.claimIds ?? []) {
686
+ if (id.startsWith("researches/")) links.findings.push(id)
687
+ else if (id.startsWith("assets/")) links.assets.push(id)
688
+ else links.materials.push(id)
689
+ }
690
+ links.caveats.push(...(input?.riskIds ?? []), ...(input?.objectionIds ?? []), ...(input?.gapIds ?? []))
691
+ return normalizeSourceLinks(links)
692
+ }
693
+
694
+ function sourceLinksForInput(input: DeckPlanSlideUpsertInput): DeckPlanSourceLinks {
695
+ return normalizeSourceLinks({
696
+ ...sourceLinksFromNarrativeLinks(input.narrativeLinks),
697
+ ...(input.sourceLinks ?? {}),
698
+ caveats: [...(sourceLinksFromNarrativeLinks(input.narrativeLinks).caveats ?? []), ...(input.sourceLinks?.caveats ?? []), ...(input.caveats ?? [])],
699
+ })
700
+ }
701
+
702
+ function parseDeckPlanSourceLinks(section: string): DeckPlanSourceLinks {
703
+ const links = emptySourceLinks()
704
+ let group: keyof DeckPlanSourceLinks | undefined
705
+ for (const rawLine of section.replace(/\r\n/g, "\n").split("\n")) {
706
+ const heading = /^\s*([A-Za-z][A-Za-z\s/-]*):\s*$/.exec(rawLine)
707
+ if (heading) {
708
+ const normalized = heading[1].trim().toLowerCase()
709
+ if (normalized.includes("material")) group = "materials"
710
+ else if (normalized.includes("finding") || normalized.includes("research")) group = "findings"
711
+ else if (normalized.includes("asset") || normalized.includes("media")) group = "assets"
712
+ else if (normalized.includes("url") || normalized.includes("link")) group = "urls"
713
+ else if (normalized.includes("caveat") || normalized.includes("risk") || normalized.includes("gap")) group = "caveats"
714
+ else group = undefined
715
+ continue
716
+ }
717
+ const bullet = rawLine.replace(/^\s*[-*]\s+/, "").trim()
718
+ if (!bullet || bullet.toLowerCase() === "none.") continue
719
+ const wikilink = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/.exec(bullet)?.[1]?.trim()
720
+ const value = wikilink || bullet
721
+ if (group) links[group].push(value)
722
+ else if (/^https?:\/\//i.test(value)) links.urls.push(value)
723
+ else if (value.startsWith("assets/")) links.assets.push(value)
724
+ else if (value.startsWith("researches/")) links.findings.push(value)
725
+ else links.materials.push(value)
726
+ }
727
+ return normalizeSourceLinks(links)
728
+ }
729
+
730
+ function sourceLinksToNarrativeLinks(sourceLinks: DeckPlanSourceLinks, compatibility: DeckPlanNarrativeLink[] = []): DeckPlanNarrativeLink[] {
731
+ const links: DeckPlanNarrativeLink[] = []
732
+ for (const id of sourceLinks.materials) links.push({ id, relation: "uses_evidence", group: "materials" })
733
+ for (const id of sourceLinks.findings) links.push({ id, relation: "uses_evidence", group: "findings" })
734
+ for (const id of sourceLinks.assets) links.push({ id, relation: "uses_evidence", group: "assets" })
735
+ for (const id of sourceLinks.urls) links.push({ id, relation: "uses_evidence", group: "urls" })
736
+ for (const id of sourceLinks.caveats) links.push({ id, relation: "mentions_gap", group: "caveats" })
737
+ return uniqueLinks([...links, ...compatibility])
738
+ }
739
+
740
+ function parseBulletText(section: string): string[] {
741
+ return section
742
+ .replace(/\r\n/g, "\n")
743
+ .split("\n")
744
+ .map((line) => line.replace(/^\s*[-*]\s+/, "").trim())
745
+ .filter((line) => line && line.toLowerCase() !== "none.")
746
+ }
747
+
399
748
  function relationForDeckPlanLink(group: string, id: string): DeckPlanNarrativeLink["relation"] {
400
749
  const normalized = group.toLowerCase()
750
+ if (normalized.includes("source") || normalized.includes("material")) return "uses_evidence"
751
+ if (normalized.includes("finding") || normalized.includes("research")) return "uses_evidence"
752
+ if (normalized.includes("asset") || normalized.includes("media")) return "uses_evidence"
401
753
  if (normalized.includes("evidence") || id.startsWith("evidence")) return "uses_evidence"
402
754
  if (normalized.includes("risk") || id.startsWith("risk")) return "addresses_risk"
403
755
  if (normalized.includes("objection") || id.startsWith("objection")) return "answers_objection"
@@ -406,6 +758,8 @@ function relationForDeckPlanLink(group: string, id: string): DeckPlanNarrativeLi
406
758
  }
407
759
 
408
760
  function inferredLinkGroup(id: string, knownNodeIds?: Set<string>): string {
761
+ if (id.startsWith("researches/")) return "findings"
762
+ if (id.startsWith("assets/")) return "assets"
409
763
  if (id.startsWith("evidence")) return "evidence"
410
764
  if (id.startsWith("risk")) return "risk"
411
765
  if (id.startsWith("objection")) return "objection"
@@ -416,7 +770,7 @@ function inferredLinkGroup(id: string, knownNodeIds?: Set<string>): string {
416
770
 
417
771
  function deckPlanIndexDiagnostics(slides: DeckPlanSlideProjection[]): DeckPlanProjectionDiagnostic[] {
418
772
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
419
- if (slides.length === 0) diagnostics.push({ severity: "warning", code: "deck_plan_slides_missing", message: "deck-plan/slides contains no slide plan Markdown files." })
773
+ if (slides.length === 0) diagnostics.push({ severity: "warning", code: "deck_plan_slides_missing", message: "deck-plan.md contains no slide blocks." })
420
774
  const seen = new Map<number, DeckPlanSlideProjection>()
421
775
  let previous = 0
422
776
  for (const slide of slides) {
@@ -435,7 +789,7 @@ function deckPlanIndexDiagnostics(slides: DeckPlanSlideProjection[]): DeckPlanPr
435
789
 
436
790
  function slideDiagnostics(slide: DeckPlanSlideProjection, knownNodeIds?: Set<string>): DeckPlanProjectionDiagnostic[] {
437
791
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
438
- 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 })
792
+ if (!slide.structural && slide.links.length === 0 && slide.caveats.length === 0) diagnostics.push({ severity: "warning", code: "slide_source_link_missing", message: `Non-structural deck-plan slide ${slide.id} has no source, research, asset, or caveat link.`, file: slide.path, nodeId: slide.id })
439
793
  if (!slide.layout) diagnostics.push({ severity: "warning", code: "slide_layout_missing", message: `Deck-plan slide ${slide.id} is missing a layout.`, file: slide.path, nodeId: slide.id })
440
794
  if (slide.components.length === 0) diagnostics.push({ severity: "warning", code: "slide_components_missing", message: `Deck-plan slide ${slide.id} has no component names in frontmatter.`, file: slide.path, nodeId: slide.id })
441
795
  if (slide.componentPlan.length === 0) diagnostics.push({ severity: "warning", code: "slide_component_plan_missing", message: `Deck-plan slide ${slide.id} is missing structured ## Component Plan entries.`, file: slide.path, nodeId: slide.id })
@@ -452,24 +806,30 @@ function slideDiagnostics(slide: DeckPlanSlideProjection, knownNodeIds?: Set<str
452
806
  return diagnostics
453
807
  }
454
808
 
455
- export function deckPlanDesignDiagnostics(projection: DeckPlanProjection | undefined, inventory: { layouts: string[]; components: string[] }): DeckPlanProjectionDiagnostic[] {
809
+ export function deckPlanDesignDiagnostics(projection: DeckPlanProjection | undefined, inventory: { layouts: string[]; components: string[]; layoutSlots?: Record<string, string[]>; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> }): DeckPlanProjectionDiagnostic[] {
456
810
  if (!projection) return []
457
811
  const layouts = new Set(inventory.layouts)
458
812
  const components = new Set(inventory.components)
459
813
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
460
814
  for (const slide of projection.slides) {
461
815
  if (slide.layout && !layouts.has(slide.layout)) diagnostics.push({ severity: "warning", code: "slide_layout_unknown", message: `Deck-plan slide ${slide.id} uses layout '${slide.layout}' outside the active design inventory.`, file: slide.path, nodeId: slide.id })
462
- for (const component of slide.components) {
463
- if (!components.has(component)) diagnostics.push({ severity: "warning", code: "slide_component_unknown", message: `Deck-plan slide ${slide.id} uses component '${component}' outside the active design inventory.`, file: slide.path, nodeId: slide.id })
464
- }
465
- for (const component of slide.componentPlan) {
466
- if (component.name && !components.has(component.name)) diagnostics.push({ severity: "warning", code: "slide_component_plan_unknown", message: `Deck-plan slide ${slide.id} component plan uses '${component.name}' outside the active design inventory.`, file: slide.path, nodeId: slide.id })
467
- }
816
+ for (const component of slide.componentPlan) diagnostics.push(...componentDesignDiagnostics(slide, component, inventory, components))
468
817
  }
469
818
  return diagnostics
470
819
  }
471
820
 
472
- function validateDeckPlanSlideUpsert(input: DeckPlanSlideUpsertInput, options: { designLayouts: string[]; designComponents: string[] }): DeckPlanProjectionDiagnostic[] {
821
+ function componentDesignDiagnostics(slide: DeckPlanSlideProjection, component: DeckPlanSlideComponentPlan, inventory: { layoutSlots?: Record<string, string[]>; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> }, components: Set<string>): DeckPlanProjectionDiagnostic[] {
822
+ const diagnostics: DeckPlanProjectionDiagnostic[] = []
823
+ if (component.name && !components.has(component.name)) diagnostics.push({ severity: "warning", code: "slide_component_plan_unknown", message: `Deck-plan slide ${slide.id} component plan uses '${component.name}' outside the active design inventory.`, file: slide.path, nodeId: slide.id })
824
+ const allowedSlots = slide.layout ? inventory.layoutSlots?.[slide.layout] : undefined
825
+ if (component.slot && allowedSlots && allowedSlots.length > 0 && !allowedSlots.includes(component.slot)) diagnostics.push({ severity: "warning", code: "slide_component_slot_invalid", message: `Deck-plan slide ${slide.id} component '${component.name}' uses slot '${component.slot}' outside layout '${slide.layout}' slots: ${allowedSlots.join(", ")}.`, file: slide.path, nodeId: slide.id })
826
+ const nesting = inventory.componentNesting?.[component.name]
827
+ if ((component.children?.length ?? 0) > 0 && nesting && !nesting.acceptsChildren) diagnostics.push({ severity: "warning", code: "slide_component_children_invalid", message: `Deck-plan slide ${slide.id} component '${component.name}' does not accept children.`, file: slide.path, nodeId: slide.id })
828
+ for (const child of component.children ?? []) diagnostics.push(...componentDesignDiagnostics(slide, child, inventory, components))
829
+ return diagnostics
830
+ }
831
+
832
+ function validateDeckPlanSlideUpsert(input: DeckPlanSlideUpsertInput, options: { designLayouts: string[]; designComponents: string[]; layoutSlots?: Record<string, string[]>; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> }): DeckPlanProjectionDiagnostic[] {
473
833
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
474
834
  const nodeId = input.id?.trim() || `slide-${input.slideIndex}`
475
835
  if (!Number.isInteger(input.slideIndex) || input.slideIndex < 1) diagnostics.push(errorDiagnostic("slide_index_invalid", "slideIndex must be a positive 1-based integer.", nodeId))
@@ -480,25 +840,54 @@ function validateDeckPlanSlideUpsert(input: DeckPlanSlideUpsertInput, options: {
480
840
  if (!Array.isArray(input.components) || input.components.length === 0) diagnostics.push(errorDiagnostic("slide_components_missing", "At least one component plan entry is required.", nodeId))
481
841
  const componentNames = new Set<string>()
482
842
  const positions = new Set<string>()
843
+ const allowedSlots = options.layoutSlots?.[input.layout]
483
844
  for (const component of input.components ?? []) {
845
+ validateComponentInput(component, { nodeId, componentNames, positions, options, allowedSlots, parentName: undefined, topLevel: true, diagnostics })
846
+ }
847
+ const visual = normalizeVisualIntent(input.visualIntent)
848
+ if (visual.component && !componentNames.has(visual.component)) diagnostics.push(errorDiagnostic("slide_visual_component_missing", `visualIntent.component '${visual.component}' is not present in component plan.`, nodeId))
849
+ const sourceLinks = sourceLinksForInput(input)
850
+ if (!input.structural && linksCount(sourceLinks) === 0) diagnostics.push({ severity: "warning", code: "slide_source_link_missing", message: "Non-structural slides should include at least one material, finding, asset, URL, or caveat source link.", nodeId })
851
+ return diagnostics
852
+ }
853
+
854
+ function validateComponentInput(component: DeckPlanSlideUpsertComponentInput, context: {
855
+ nodeId: string
856
+ componentNames: Set<string>
857
+ positions: Set<string>
858
+ options: { designComponents: string[]; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> }
859
+ allowedSlots?: string[]
860
+ parentName?: string
861
+ topLevel: boolean
862
+ diagnostics: DeckPlanProjectionDiagnostic[]
863
+ }): void {
484
864
  const name = component.name?.trim()
485
- if (name) componentNames.add(name)
486
- if (!name) diagnostics.push(errorDiagnostic("slide_component_name_missing", "Every component requires name.", nodeId))
487
- else if (!options.designComponents.includes(name)) diagnostics.push(errorDiagnostic("slide_component_unknown", `Component '${name}' is not in the selected design inventory.`, nodeId))
865
+ if (name) context.componentNames.add(name)
866
+ if (!name) context.diagnostics.push(errorDiagnostic("slide_component_name_missing", "Every component requires name.", context.nodeId))
867
+ else if (!context.options.designComponents.includes(name)) context.diagnostics.push(errorDiagnostic("slide_component_unknown", `Component '${name}' is not in the selected design inventory.`, context.nodeId))
488
868
  for (const key of ["slot", "position", "purpose", "content"] as const) {
489
- if (!String(component[key] || "").trim()) diagnostics.push(errorDiagnostic("slide_component_plan_incomplete", `Component '${name || "unnamed"}' is missing ${key}.`, nodeId))
869
+ if (!String(component[key] || "").trim()) context.diagnostics.push(errorDiagnostic("slide_component_plan_incomplete", `Component '${name || "unnamed"}' is missing ${key}.`, context.nodeId))
490
870
  }
491
- if (component.position && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(component.position)) diagnostics.push(errorDiagnostic("slide_component_position_invalid", `Component '${name || "unnamed"}' position must be a non-empty kebab-case anchor.`, nodeId))
871
+ if (context.topLevel && component.slot && context.allowedSlots && context.allowedSlots.length > 0 && !context.allowedSlots.includes(component.slot.trim())) context.diagnostics.push(errorDiagnostic("slide_component_slot_invalid", `Component '${name || "unnamed"}' slot '${component.slot}' is not valid for this layout. Allowed slots: ${context.allowedSlots.join(", ")}.`, context.nodeId))
872
+ if (component.position && !/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(component.position)) context.diagnostics.push(errorDiagnostic("slide_component_position_invalid", `Component '${name || "unnamed"}' position must be a non-empty kebab-case anchor.`, context.nodeId))
492
873
  const positionKey = `${component.slot?.trim() || ""}:${component.position?.trim() || ""}`
493
874
  if (component.slot?.trim() && component.position?.trim()) {
494
- if (positions.has(positionKey)) diagnostics.push(errorDiagnostic("slide_component_position_duplicate", `Duplicate component slot/position '${positionKey}' makes the plan ambiguous.`, nodeId))
495
- positions.add(positionKey)
875
+ if (context.positions.has(positionKey)) context.diagnostics.push(errorDiagnostic("slide_component_position_duplicate", `Duplicate component slot/position '${positionKey}' makes the plan ambiguous.`, context.nodeId))
876
+ context.positions.add(positionKey)
496
877
  }
497
- }
498
- const visual = normalizeVisualIntent(input.visualIntent)
499
- if (visual.component && !componentNames.has(visual.component)) diagnostics.push(errorDiagnostic("slide_visual_component_missing", `visualIntent.component '${visual.component}' is not present in component plan.`, nodeId))
500
- if (!input.structural && !((input.narrativeLinks?.claimIds?.length ?? 0) > 0 || (input.narrativeLinks?.evidenceIds?.length ?? 0) > 0)) diagnostics.push({ severity: "warning", code: "slide_narrative_link_missing", message: "Non-structural slides should include at least one claim or evidence narrative link.", nodeId })
501
- return diagnostics
878
+ const children = component.children ?? []
879
+ const nesting = name ? context.options.componentNesting?.[name] : undefined
880
+ if (children.length > 0 && nesting && !nesting.acceptsChildren) context.diagnostics.push(errorDiagnostic("slide_component_children_invalid", `Component '${name}' does not accept children. Use box as the semantic container.`, context.nodeId))
881
+ if (children.length > 0 && nesting?.allowedChildren) {
882
+ for (const child of children) {
883
+ if (child.name && !nesting.allowedChildren.includes(child.name)) context.diagnostics.push(errorDiagnostic("slide_component_child_invalid", `Component '${name}' cannot contain child '${child.name}'.`, context.nodeId))
884
+ }
885
+ }
886
+ for (const child of children) validateComponentInput(child, { ...context, parentName: name, topLevel: false })
887
+ }
888
+
889
+ function linksCount(sourceLinks: DeckPlanSourceLinks): number {
890
+ return sourceLinks.materials.length + sourceLinks.findings.length + sourceLinks.assets.length + sourceLinks.urls.length + sourceLinks.caveats.length
502
891
  }
503
892
 
504
893
  function errorDiagnostic(code: string, message: string, nodeId?: string): DeckPlanProjectionDiagnostic {
@@ -508,22 +897,33 @@ function errorDiagnostic(code: string, message: string, nodeId?: string): DeckPl
508
897
  function parseDeckPlanComponentPlan(section: string): DeckPlanSlideComponentPlan[] {
509
898
  const components: DeckPlanSlideComponentPlan[] = []
510
899
  let current: DeckPlanSlideComponentPlan | undefined
900
+ let currentChild: DeckPlanSlideComponentPlan | undefined
511
901
  let capture: "content" | undefined
512
902
  const flush = () => {
903
+ if (currentChild && current) {
904
+ current.children = [...(current.children ?? []), normalizeComponentPlan(currentChild)]
905
+ currentChild = undefined
906
+ }
513
907
  if (current) components.push({
514
- ...current,
515
- content: current.content.trim(),
516
- claimIds: uniqueStrings(current.claimIds),
517
- evidenceIds: uniqueStrings(current.evidenceIds),
518
- sourceNotes: current.sourceNotes.filter(Boolean),
519
- renderNotes: current.renderNotes.filter(Boolean),
908
+ ...normalizeComponentPlan(current),
909
+ children: current.children,
520
910
  })
521
911
  }
912
+ const target = () => currentChild ?? current
913
+ const startComponent = (name: string, child: boolean) => {
914
+ if (child && current) {
915
+ if (currentChild) current.children = [...(current.children ?? []), normalizeComponentPlan(currentChild)]
916
+ currentChild = blankComponentPlan(name)
917
+ } else {
918
+ flush()
919
+ current = blankComponentPlan(name)
920
+ currentChild = undefined
921
+ }
922
+ }
522
923
  for (const rawLine of section.replace(/\r\n/g, "\n").split("\n")) {
523
- const heading = /^###\s+(.+?)\s*$/.exec(rawLine)
924
+ const heading = /^(#{3,6})\s+(.+?)\s*$/.exec(rawLine)
524
925
  if (heading) {
525
- flush()
526
- current = { name: heading[1].trim(), slot: "", position: "", purpose: "", content: "", claimIds: [], evidenceIds: [], sourceNotes: [], renderNotes: [] }
926
+ startComponent(heading[2].trim(), heading[1].length > 5)
527
927
  capture = undefined
528
928
  continue
529
929
  }
@@ -534,25 +934,46 @@ function parseDeckPlanComponentPlan(section: string): DeckPlanSlideComponentPlan
534
934
  capture = undefined
535
935
  const key = field[1].toLowerCase()
536
936
  const value = field[2].trim()
537
- if (key === "slot") current.slot = value
538
- else if (key === "position") current.position = value
539
- else if (key === "placement note") current.placementNote = value
540
- else if (key === "purpose") current.purpose = value
937
+ const item = target()
938
+ if (!item) continue
939
+ if (key === "slot") item.slot = value
940
+ else if (key === "position") item.position = value
941
+ else if (key === "placement note") item.placementNote = value
942
+ else if (key === "purpose") item.purpose = value
541
943
  else if (key === "content") {
542
- current.content = cleanPlanValue(value)
944
+ item.content = cleanPlanValue(value)
543
945
  capture = value ? undefined : "content"
544
- } else if (key === "claim ids") current.claimIds = parseCsv(value)
545
- else if (key === "evidence ids") current.evidenceIds = parseCsv(value)
546
- else if (key === "source notes") current.sourceNotes = parseListValue(value)
547
- else if (key === "render notes") current.renderNotes = parseListValue(value)
946
+ } else if (key === "claim ids") item.claimIds = parseCsv(value)
947
+ else if (key === "evidence ids") item.evidenceIds = parseCsv(value)
948
+ else if (key === "source notes") item.sourceNotes = parseListValue(value)
949
+ else if (key === "render notes") item.renderNotes = parseListValue(value)
548
950
  continue
549
951
  }
550
- if (capture === "content" && rawLine.trim()) current.content += `${current.content ? "\n" : ""}${rawLine.replace(/^\s{2}/, "")}`
952
+ if (capture === "content" && rawLine.trim()) {
953
+ const item = target()
954
+ if (item) item.content += `${item.content ? "\n" : ""}${rawLine.replace(/^\s{2}/, "")}`
955
+ }
551
956
  }
552
957
  flush()
553
958
  return components
554
959
  }
555
960
 
961
+ function blankComponentPlan(name: string): DeckPlanSlideComponentPlan {
962
+ return { name, slot: "", position: "", purpose: "", content: "", claimIds: [], evidenceIds: [], sourceNotes: [], renderNotes: [] }
963
+ }
964
+
965
+ function normalizeComponentPlan(component: DeckPlanSlideComponentPlan): DeckPlanSlideComponentPlan {
966
+ return {
967
+ ...component,
968
+ content: component.content.trim(),
969
+ claimIds: uniqueStrings(component.claimIds),
970
+ evidenceIds: uniqueStrings(component.evidenceIds),
971
+ sourceNotes: component.sourceNotes.filter(Boolean),
972
+ renderNotes: component.renderNotes.filter(Boolean),
973
+ children: component.children?.map(normalizeComponentPlan),
974
+ }
975
+ }
976
+
556
977
  function renderDeckPlanSlideMarkdown(input: DeckPlanSlideUpsertInput & { id: string }): string {
557
978
  const components = input.components.map((component) => component.name.trim())
558
979
  const lines: string[] = []
@@ -595,23 +1016,7 @@ function renderDeckPlanSlideMarkdown(input: DeckPlanSlideUpsertInput & { id: str
595
1016
  lines.push(`- Render notes: ${formatListValue(component.renderNotes)}`)
596
1017
  lines.push("")
597
1018
  }
598
- lines.push("## Narrative Links")
599
- lines.push("")
600
- lines.push("Claims:")
601
- for (const id of input.narrativeLinks?.claimIds ?? []) lines.push(`- [[${id}]]`)
602
- lines.push("")
603
- lines.push("Evidence:")
604
- for (const id of input.narrativeLinks?.evidenceIds ?? []) lines.push(`- [[${id}]]`)
605
- lines.push("")
606
- lines.push("Risks:")
607
- for (const id of input.narrativeLinks?.riskIds ?? []) lines.push(`- [[${id}]]`)
608
- lines.push("")
609
- lines.push("Objections:")
610
- for (const id of input.narrativeLinks?.objectionIds ?? []) lines.push(`- [[${id}]]`)
611
- lines.push("")
612
- lines.push("Gaps:")
613
- for (const id of input.narrativeLinks?.gapIds ?? []) lines.push(`- [[${id}]]`)
614
- lines.push("")
1019
+ lines.push(renderSourceLinksMarkdown(sourceLinksForInput(input)).replace(/^####/gm, "##"))
615
1020
  lines.push("## Caveats")
616
1021
  lines.push("")
617
1022
  const caveats = input.caveats?.filter((item) => item.trim()) ?? []
@@ -650,8 +1055,8 @@ function renderMinimalDeckPlanIndex(narrativeHash: string | undefined, slides: D
650
1055
  lines.push("")
651
1056
  lines.push("## Source Authority")
652
1057
  lines.push("")
653
- lines.push("- Meaning: `revela-narrative/` remains canonical.")
654
- lines.push("- Render planning: `deck-plan/` is an execution projection, not approval state.")
1058
+ lines.push("- Sources: local materials, reviewed findings, workspace assets, URLs, and user intent.")
1059
+ lines.push("- Render planning: `deck-plan.md` is the execution blueprint for HTML deck generation.")
655
1060
  lines.push("")
656
1061
  lines.push("## Audience / Goal / Decision")
657
1062
  lines.push("")
@@ -669,7 +1074,7 @@ function renderMinimalDeckPlanIndex(narrativeHash: string | undefined, slides: D
669
1074
  lines.push("")
670
1075
  lines.push("## Slide Plan")
671
1076
  lines.push("")
672
- if (slides.length === 0) lines.push("- No slide files yet.")
1077
+ if (slides.length === 0) lines.push("- No slide blocks yet.")
673
1078
  else for (const slide of slides) lines.push(`- Slide ${slide.slideIndex}: [[${slide.id}]] - ${slide.title} (${slide.path}); layout ${slide.layout || "unspecified"}; components ${slide.components.join(", ") || "none"}.`)
674
1079
  lines.push("")
675
1080
  lines.push("## Evidence Trace")