@cyber-dash-tech/revela 0.17.24 → 0.18.3

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 +98 -19
  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 +631 -114
  13. package/lib/narrative-state/render-plan.ts +53 -24
  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 +16 -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 +44 -35
  34. package/plugins/revela/skills/revela-story/SKILL.md +0 -24
@@ -9,10 +9,12 @@ 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
17
+ export const MAX_HTML_SLIDES_PER_BATCH = 5
16
18
 
17
19
  export interface DeckPlanArtifactInput {
18
20
  deck: DeckSpec
@@ -65,13 +67,24 @@ export interface DeckPlanProjection {
65
67
  frontmatter: Record<string, string | string[] | boolean>
66
68
  sections: string[]
67
69
  narrativeHash?: string
70
+ designName?: string
68
71
  outputPath?: string
69
72
  slides: DeckPlanSlideProjection[]
73
+ htmlWritingBatches: DeckPlanHtmlWritingBatch[]
74
+ htmlWritingInstruction: string
70
75
  graphNodes: Array<{ id: string; type: WorkspaceGraphNodeType; file: string }>
71
76
  graphRelations: VaultRelation[]
72
77
  diagnostics: DeckPlanProjectionDiagnostic[]
73
78
  }
74
79
 
80
+ export interface DeckPlanHtmlWritingBatch {
81
+ label: string
82
+ chapterTitle: string
83
+ slideIndexes: number[]
84
+ maxSlides: number
85
+ instructions: string
86
+ }
87
+
75
88
  export interface DeckPlanSlideProjection {
76
89
  path: string
77
90
  absolutePath: string
@@ -88,6 +101,8 @@ export interface DeckPlanSlideProjection {
88
101
  frontmatter: Record<string, string | string[] | boolean>
89
102
  sections: string[]
90
103
  links: DeckPlanNarrativeLink[]
104
+ sourceLinks: DeckPlanSourceLinks
105
+ caveats: string[]
91
106
  }
92
107
 
93
108
  export interface DeckPlanSlideComponentPlan {
@@ -101,6 +116,7 @@ export interface DeckPlanSlideComponentPlan {
101
116
  sourceNotes: string[]
102
117
  renderNotes: string[]
103
118
  placementNote?: string
119
+ children?: DeckPlanSlideComponentPlan[]
104
120
  }
105
121
 
106
122
  export interface DeckPlanSlideUpsertComponentInput {
@@ -114,9 +130,20 @@ export interface DeckPlanSlideUpsertComponentInput {
114
130
  sourceNotes?: string[]
115
131
  renderNotes?: string[]
116
132
  placementNote?: string
133
+ children?: DeckPlanSlideUpsertComponentInput[]
134
+ }
135
+
136
+ export interface DeckPlanSourceLinks {
137
+ materials: string[]
138
+ findings: string[]
139
+ assets: string[]
140
+ urls: string[]
141
+ caveats: string[]
117
142
  }
118
143
 
119
144
  export interface DeckPlanSlideUpsertInput {
145
+ designName?: string
146
+ outputPath?: string
120
147
  slideIndex: number
121
148
  id?: string
122
149
  title: string
@@ -131,7 +158,8 @@ export interface DeckPlanSlideUpsertInput {
131
158
  rationale?: string
132
159
  brief?: string
133
160
  } | string
134
- narrativeLinks: {
161
+ sourceLinks?: Partial<DeckPlanSourceLinks>
162
+ narrativeLinks?: {
135
163
  claimIds?: string[]
136
164
  evidenceIds?: string[]
137
165
  riskIds?: string[]
@@ -165,15 +193,14 @@ export interface DeckPlanProjectionDiagnostic {
165
193
  }
166
194
 
167
195
  export const REQUIRED_DECK_PLAN_SECTIONS = [
196
+ "Goal",
197
+ "Audience",
198
+ "Design",
168
199
  "Source Authority",
169
- "Audience / Goal / Decision",
170
- "Deck Parameters",
171
200
  "Chapter Map",
172
- "Slide Plan",
173
- "Evidence Trace",
174
- "Boundary / Risk Treatment",
175
- "Chapter Writing Batches",
176
- "HTML Identity Contract",
201
+ "Slides",
202
+ "Unresolved Inputs",
203
+ "HTML Contract",
177
204
  ]
178
205
 
179
206
  export function writeDeckPlanArtifact(workspaceRoot: string, input: DeckPlanArtifactInput): { path: string; absolutePath: string } {
@@ -185,17 +212,17 @@ export function writeDeckPlanArtifact(workspaceRoot: string, input: DeckPlanArti
185
212
 
186
213
  export function readDeckPlanArtifact(workspaceRoot: string, expected?: { narrativeHash?: string; knownNodeIds?: Set<string> }): DeckPlanReadResult {
187
214
  const projection = readDeckPlanProjection(workspaceRoot, expected)
188
- const absolutePath = projection?.absolutePath ?? join(workspaceRoot, DECK_PLAN_INDEX_PATH)
215
+ const absolutePath = projection?.absolutePath ?? join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH)
189
216
  if (!existsSync(absolutePath)) {
190
217
  return {
191
218
  ok: false,
192
- path: DECK_PLAN_INDEX_PATH,
219
+ path: DECK_PLAN_MARKDOWN_PATH,
193
220
  absolutePath,
194
221
  approvalStatus: "missing",
195
222
  sections: [],
196
223
  missingSections: REQUIRED_DECK_PLAN_SECTIONS,
197
224
  warnings: [],
198
- reason: `Deck plan file is missing: ${DECK_PLAN_INDEX_PATH}. Write the LLM-authored deck-plan/ projection before HTML generation.`,
225
+ reason: `Deck plan file is missing: ${DECK_PLAN_MARKDOWN_PATH}. Write the LLM-authored deck plan before HTML generation.`,
199
226
  }
200
227
  }
201
228
  const markdown = projection?.markdown ?? readFileSync(absolutePath, "utf-8")
@@ -221,7 +248,7 @@ export function readDeckPlanArtifact(workspaceRoot: string, expected?: { narrati
221
248
  }
222
249
  return {
223
250
  ok: true,
224
- path: projection?.path ?? DECK_PLAN_INDEX_PATH,
251
+ path: projection?.path ?? DECK_PLAN_MARKDOWN_PATH,
225
252
  absolutePath,
226
253
  markdown,
227
254
  planHash,
@@ -236,9 +263,10 @@ export function readDeckPlanArtifact(workspaceRoot: string, expected?: { narrati
236
263
 
237
264
  export function readDeckPlanProjection(workspaceRoot: string, expected?: { narrativeHash?: string; knownNodeIds?: Set<string> }): DeckPlanProjection | undefined {
238
265
  const root = join(workspaceRoot, DECK_PLAN_DIR)
266
+ const singlePath = join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH)
239
267
  const indexPath = join(workspaceRoot, DECK_PLAN_INDEX_PATH)
240
268
  const legacyPath = join(workspaceRoot, LEGACY_DECK_PLAN_ARTIFACT_PATH)
241
- const absolutePath = existsSync(indexPath) ? indexPath : existsSync(legacyPath) ? legacyPath : ""
269
+ const absolutePath = existsSync(singlePath) ? singlePath : existsSync(indexPath) ? indexPath : existsSync(legacyPath) ? legacyPath : ""
242
270
  if (!absolutePath) return undefined
243
271
  const markdown = readFileSync(absolutePath, "utf-8")
244
272
  const parsed = parseVaultFrontmatter(markdown)
@@ -246,7 +274,8 @@ export function readDeckPlanProjection(workspaceRoot: string, expected?: { narra
246
274
  const sections = parseMarkdownSections(markdown)
247
275
  const path = relativePath(workspaceRoot, absolutePath)
248
276
  const id = stringField(parsed.frontmatter, "id") || "deck-plan"
249
- const slides = existsSync(join(root, "slides")) ? readDeckPlanSlideFiles(workspaceRoot, expected?.knownNodeIds) : []
277
+ const isSingleFile = relativePath(workspaceRoot, absolutePath) === DECK_PLAN_MARKDOWN_PATH
278
+ const slides = isSingleFile ? readDeckPlanSlidesFromSingleFile(workspaceRoot, absolutePath, markdown, expected?.knownNodeIds) : existsSync(join(root, "slides")) ? readDeckPlanSlideFiles(workspaceRoot, expected?.knownNodeIds) : []
250
279
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
251
280
  const narrativeHash = stringField(parsed.frontmatter, "narrativeHash") || narrativeHashFromMarkdown(markdown)
252
281
  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 })
@@ -265,6 +294,7 @@ export function readDeckPlanProjection(workspaceRoot: string, expected?: { narra
265
294
  file: slide.path,
266
295
  source: "inline" as const,
267
296
  })))
297
+ const htmlWritingBatches = buildHtmlWritingBatches(slides)
268
298
  return {
269
299
  path,
270
300
  absolutePath,
@@ -273,8 +303,11 @@ export function readDeckPlanProjection(workspaceRoot: string, expected?: { narra
273
303
  frontmatter: parsed.frontmatter,
274
304
  sections,
275
305
  narrativeHash,
276
- outputPath: stringField(parsed.frontmatter, "outputPath"),
306
+ designName: stringField(parsed.frontmatter, "designName") || stringField(parsed.frontmatter, "design"),
307
+ outputPath: stringField(parsed.frontmatter, "outputPath") || stringField(parsed.frontmatter, "output"),
277
308
  slides,
309
+ htmlWritingBatches,
310
+ htmlWritingInstruction: htmlWritingInstruction(),
278
311
  graphNodes,
279
312
  graphRelations,
280
313
  diagnostics,
@@ -311,36 +344,212 @@ export function deckPlanBodyHash(markdown: string): string {
311
344
  export function upsertDeckPlanSlideArtifact(
312
345
  workspaceRoot: string,
313
346
  input: DeckPlanSlideUpsertInput,
314
- options: { narrativeHash?: string; knownNodeIds?: Set<string>; designLayouts: string[]; designComponents: string[] },
347
+ options: { narrativeHash?: string; knownNodeIds?: Set<string>; designLayouts: string[]; designComponents: string[]; layoutSlots?: Record<string, string[]>; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> },
315
348
  ): DeckPlanSlideUpsertResult {
316
349
  const diagnostics = validateDeckPlanSlideUpsert(input, options)
317
350
  if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) return { ok: false, diagnostics }
318
351
 
319
- mkdirSync(join(workspaceRoot, DECK_PLAN_SLIDES_DIR), { recursive: true })
320
- ensureDeckPlanIndex(workspaceRoot, options.narrativeHash)
321
-
322
352
  const existing = readDeckPlanProjection(workspaceRoot, { narrativeHash: options.narrativeHash, knownNodeIds: options.knownNodeIds })
323
353
  const existingSlide = existing?.slides.find((slide) => slide.slideIndex === input.slideIndex)
324
354
  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)
355
+ const nextSlide = projectionFromSlideInput(workspaceRoot, { ...input, id }, existingSlide)
356
+ const slides = [...(existing?.slides.filter((slide) => slide.slideIndex !== input.slideIndex) ?? []), nextSlide]
357
+ .sort((a, b) => (a.slideIndex ?? Number.MAX_SAFE_INTEGER) - (b.slideIndex ?? Number.MAX_SAFE_INTEGER))
358
+ const designName = input.designName || existing?.designName
359
+ const outputPath = input.outputPath || existing?.outputPath
360
+ writeDeckPlanSingleFile(workspaceRoot, {
361
+ title: "Deck Plan",
362
+ designName,
363
+ outputPath,
364
+ slides,
365
+ })
341
366
  const projection = readDeckPlanProjection(workspaceRoot, { narrativeHash: options.narrativeHash, knownNodeIds: options.knownNodeIds })
342
367
  const slide = projection?.slides.find((item) => item.slideIndex === input.slideIndex)
343
- return { ok: true, path, absolutePath, updated: Boolean(existingSlide), slide, diagnostics: [...diagnostics, ...(projection?.diagnostics ?? [])] }
368
+ return { ok: true, path: DECK_PLAN_MARKDOWN_PATH, absolutePath: join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH), updated: Boolean(existingSlide), slide, diagnostics: [...diagnostics, ...(projection?.diagnostics ?? [])] }
369
+ }
370
+
371
+ function projectionFromSlideInput(workspaceRoot: string, input: DeckPlanSlideUpsertInput & { id: string }, existing?: DeckPlanSlideProjection): DeckPlanSlideProjection {
372
+ const sourceLinks = sourceLinksForInput(input)
373
+ const caveats = uniqueStrings([...(input.caveats ?? []), ...sourceLinks.caveats])
374
+ const componentPlan = input.components.map(componentInputToPlan)
375
+ const slide: DeckPlanSlideProjection = {
376
+ path: DECK_PLAN_MARKDOWN_PATH,
377
+ absolutePath: join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH),
378
+ id: input.id,
379
+ slideIndex: input.slideIndex,
380
+ title: input.title,
381
+ chapter: input.chapter,
382
+ layout: input.layout,
383
+ components: uniqueStrings(componentPlan.flatMap(flattenComponentNames)),
384
+ componentPlan,
385
+ structural: input.structural ?? false,
386
+ narrativeRole: input.narrativeRole,
387
+ markdown: "",
388
+ frontmatter: existing?.frontmatter ?? {},
389
+ sections: [],
390
+ links: sourceLinksToNarrativeLinks(sourceLinks, input.narrativeLinks ? sourceLinksToNarrativeLinks(sourceLinksFromNarrativeLinks(input.narrativeLinks)) : []),
391
+ sourceLinks,
392
+ caveats,
393
+ }
394
+ slide.markdown = renderDeckPlanSlideBlock(slide, input.visualIntent)
395
+ return slide
396
+ }
397
+
398
+ function componentInputToPlan(component: DeckPlanSlideUpsertComponentInput): DeckPlanSlideComponentPlan {
399
+ return normalizeComponentPlan({
400
+ name: component.name?.trim() || "",
401
+ slot: component.slot?.trim() || "",
402
+ position: component.position?.trim() || "",
403
+ purpose: component.purpose?.trim() || "",
404
+ content: component.content?.trim() || "",
405
+ claimIds: uniqueStrings(component.claimIds ?? []),
406
+ evidenceIds: uniqueStrings(component.evidenceIds ?? []),
407
+ sourceNotes: (component.sourceNotes ?? []).map((item) => item.trim()).filter(Boolean),
408
+ renderNotes: (component.renderNotes ?? []).map((item) => item.trim()).filter(Boolean),
409
+ placementNote: component.placementNote?.trim(),
410
+ children: component.children?.map(componentInputToPlan),
411
+ })
412
+ }
413
+
414
+ function flattenComponentNames(component: DeckPlanSlideComponentPlan): string[] {
415
+ return [component.name, ...(component.children ?? []).flatMap(flattenComponentNames)].filter(Boolean)
416
+ }
417
+
418
+ function writeDeckPlanSingleFile(workspaceRoot: string, input: {
419
+ title: string
420
+ goal?: string
421
+ audience?: string
422
+ designName?: string
423
+ outputPath?: string
424
+ sourceAuthority?: string[]
425
+ unresolvedInputs?: string[]
426
+ slides: DeckPlanSlideProjection[]
427
+ }): { path: string; absolutePath: string } {
428
+ const absolutePath = join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH)
429
+ const lines: string[] = []
430
+ lines.push("---")
431
+ lines.push("type: deck-plan")
432
+ lines.push("version: 0.18.1")
433
+ if (input.designName) lines.push(`designName: ${yamlScalar(input.designName)}`)
434
+ if (input.outputPath) lines.push(`outputPath: ${yamlScalar(input.outputPath)}`)
435
+ lines.push("---")
436
+ lines.push("")
437
+ lines.push(`# ${input.title || "Deck Plan"}`)
438
+ lines.push("")
439
+ lines.push("## Goal")
440
+ lines.push("")
441
+ lines.push(input.goal || "To be specified from user intent and source materials.")
442
+ lines.push("")
443
+ lines.push("## Audience")
444
+ lines.push("")
445
+ lines.push(input.audience || "To be specified.")
446
+ lines.push("")
447
+ lines.push("## Design")
448
+ lines.push("")
449
+ lines.push(`- Design: ${input.designName || "active design"}`)
450
+ if (input.outputPath) lines.push(`- Output path: ${input.outputPath}`)
451
+ lines.push("")
452
+ lines.push("## Source Authority")
453
+ lines.push("")
454
+ 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."]
455
+ for (const item of sourceAuthority) lines.push(`- ${item}`)
456
+ lines.push("")
457
+ lines.push("## Chapter Map")
458
+ lines.push("")
459
+ const chapterMap = new Map<string, number[]>()
460
+ for (const slide of input.slides) chapterMap.set(slide.chapter || "Unassigned", [...(chapterMap.get(slide.chapter || "Unassigned") ?? []), slide.slideIndex ?? 0].filter(Boolean))
461
+ for (const [chapter, indexes] of chapterMap) lines.push(`- ${chapter}: slides ${formatSlideRange(indexes)}`)
462
+ if (chapterMap.size === 0) lines.push("- No slides planned yet.")
463
+ lines.push("")
464
+ lines.push("## Slides")
465
+ lines.push("")
466
+ for (const slide of input.slides) {
467
+ lines.push(renderDeckPlanSlideBlock(slide))
468
+ lines.push("")
469
+ }
470
+ lines.push("## Unresolved Inputs")
471
+ lines.push("")
472
+ const unresolved = input.unresolvedInputs?.filter(Boolean) ?? []
473
+ if (unresolved.length === 0) lines.push("- None.")
474
+ else for (const item of unresolved) lines.push(`- ${item}`)
475
+ lines.push("")
476
+ lines.push("## HTML Contract")
477
+ lines.push("")
478
+ lines.push("- Render one `<section class=\"slide\" data-slide-index=\"N\">` per planned slide.")
479
+ lines.push("- Use positive 1-based slide indexes, unique indexes, DOM order, and one direct `.slide-canvas` child per slide.")
480
+ lines.push("")
481
+ writeFileSync(absolutePath, lines.join("\n"), "utf-8")
482
+ return { path: DECK_PLAN_MARKDOWN_PATH, absolutePath }
483
+ }
484
+
485
+ function renderDeckPlanSlideBlock(slide: DeckPlanSlideProjection, visualIntent?: DeckPlanSlideUpsertInput["visualIntent"]): string {
486
+ const lines: string[] = []
487
+ lines.push("---")
488
+ lines.push(`slideIndex: ${slide.slideIndex ?? ""}`)
489
+ lines.push(`id: ${slide.id}`)
490
+ lines.push(`title: ${yamlScalar(slide.title)}`)
491
+ lines.push(`chapter: ${yamlScalar(slide.chapter || "Unassigned")}`)
492
+ lines.push(`role: ${yamlScalar(slide.narrativeRole || "Not specified")}`)
493
+ lines.push(`structural: ${slide.structural ? "true" : "false"}`)
494
+ lines.push(`layout: ${slide.layout || "unspecified"}`)
495
+ lines.push(`components: ${slide.components.join(", ") || "none"}`)
496
+ lines.push("---")
497
+ lines.push("")
498
+ lines.push("#### Content Plan")
499
+ lines.push("")
500
+ lines.push(`- Message: ${slide.narrativeRole || "Not specified."}`)
501
+ lines.push(`- Role: ${slide.narrativeRole || "Not specified"}`)
502
+ lines.push("- Speaker notes: Not specified.")
503
+ lines.push("")
504
+ lines.push("#### Source Links")
505
+ lines.push("")
506
+ lines.push(renderSourceLinksMarkdown(slide.sourceLinks))
507
+ lines.push("")
508
+ lines.push("#### Design Plan")
509
+ lines.push("")
510
+ lines.push(`- Layout: ${slide.layout || "unspecified"}`)
511
+ lines.push(`- Components: ${slide.components.join(", ") || "none"}`)
512
+ lines.push("- Visual intent:")
513
+ lines.push(...indentMultiline(visualIntent ? renderVisualIntent(visualIntent) : "- Brief: Not specified."))
514
+ lines.push("")
515
+ for (const component of slide.componentPlan) lines.push(renderComponentPlanMarkdown(component, 5))
516
+ lines.push("")
517
+ return lines.join("\n")
518
+ }
519
+
520
+ function renderComponentPlanMarkdown(component: DeckPlanSlideComponentPlan, headingLevel: number): string {
521
+ const lines: string[] = []
522
+ lines.push(`${"#".repeat(headingLevel)} ${component.name}`)
523
+ lines.push("")
524
+ lines.push(`- Slot: ${component.slot}`)
525
+ lines.push(`- Position: ${component.position}`)
526
+ if (component.placementNote) lines.push(`- Placement note: ${component.placementNote}`)
527
+ lines.push(`- Purpose: ${component.purpose}`)
528
+ lines.push("- Content:")
529
+ lines.push(...indentMultiline(component.content))
530
+ lines.push(`- Claim ids: ${formatCsv(component.claimIds)}`)
531
+ lines.push(`- Evidence ids: ${formatCsv(component.evidenceIds)}`)
532
+ lines.push(`- Source notes: ${formatListValue(component.sourceNotes)}`)
533
+ lines.push(`- Render notes: ${formatListValue(component.renderNotes)}`)
534
+ lines.push("")
535
+ for (const child of component.children ?? []) lines.push(renderComponentPlanMarkdown(child, headingLevel + 1))
536
+ return lines.join("\n")
537
+ }
538
+
539
+ function renderSourceLinksMarkdown(sourceLinks: DeckPlanSourceLinks): string {
540
+ const lines: string[] = []
541
+ for (const [label, values] of [
542
+ ["Materials", sourceLinks.materials],
543
+ ["Findings", sourceLinks.findings],
544
+ ["Assets", sourceLinks.assets],
545
+ ["URLs", sourceLinks.urls],
546
+ ] as const) {
547
+ lines.push(`${label}:`)
548
+ if (values.length === 0) lines.push("- None.")
549
+ else for (const value of values) lines.push(value.includes("/") && !/^https?:\/\//i.test(value) ? `- [[${value}]]` : `- ${value}`)
550
+ lines.push("")
551
+ }
552
+ return lines.join("\n").trim()
344
553
  }
345
554
 
346
555
  function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string>): DeckPlanSlideProjection[] {
@@ -356,7 +565,9 @@ function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string
356
565
  const path = relativePath(workspaceRoot, absolutePath)
357
566
  const id = stringField(parsed.frontmatter, "id") || fileId(entry)
358
567
  const componentPlan = parseDeckPlanComponentPlan(split.sections["component-plan"] ?? "")
359
- const links = parseDeckPlanNarrativeLinks(split.sections["narrative-links"] ?? parsed.body, knownNodeIds)
568
+ const sourceLinks = parseDeckPlanSourceLinks(split.sections["source-links"] ?? "")
569
+ const links = sourceLinksToNarrativeLinks(sourceLinks, parseDeckPlanNarrativeLinks(split.sections["narrative-links"] ?? parsed.body, knownNodeIds))
570
+ const caveats = parseBulletText(split.sections["caveats"] ?? "")
360
571
  slides.push({
361
572
  path,
362
573
  absolutePath,
@@ -373,11 +584,134 @@ function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string
373
584
  frontmatter: parsed.frontmatter,
374
585
  sections: parseMarkdownSections(markdown),
375
586
  links,
587
+ sourceLinks,
588
+ caveats,
376
589
  })
377
590
  }
378
591
  return slides.sort((a, b) => (a.slideIndex ?? Number.MAX_SAFE_INTEGER) - (b.slideIndex ?? Number.MAX_SAFE_INTEGER) || a.path.localeCompare(b.path))
379
592
  }
380
593
 
594
+ function readDeckPlanSlidesFromSingleFile(workspaceRoot: string, absolutePath: string, markdown: string, knownNodeIds?: Set<string>): DeckPlanSlideProjection[] {
595
+ const slideBlocks = readDeckPlanSeparatorSlidesFromSingleFile(workspaceRoot, absolutePath, markdown, knownNodeIds)
596
+ if (slideBlocks.length > 0) return slideBlocks
597
+ const path = relativePath(workspaceRoot, absolutePath)
598
+ const body = parseVaultFrontmatter(markdown).body
599
+ const matches = [...body.matchAll(/^[ \t]*###\s+Slide\s+(\d+)\s+(?:—|-)\s+(.+?)\s*$/gm)]
600
+ const slides: DeckPlanSlideProjection[] = []
601
+ for (let i = 0; i < matches.length; i++) {
602
+ const match = matches[i]
603
+ const start = match.index ?? 0
604
+ const nextSlide = i + 1 < matches.length ? matches[i + 1].index ?? body.length : body.length
605
+ const headingEnd = body.indexOf("\n", start)
606
+ const searchStart = headingEnd === -1 ? start + match[0].length : headingEnd + 1
607
+ const nextSection = body.slice(searchStart).search(/^[ \t]*##\s+(?!#)/m)
608
+ const end = Math.min(nextSlide, nextSection === -1 ? body.length : searchStart + nextSection)
609
+ const block = body.slice(start, end).trim()
610
+ const slideIndex = Number(match[1])
611
+ const title = match[2].trim()
612
+ const fields = parseSlideBlockFields(block)
613
+ const id = fields.id || `slide-${slugify(title)}`
614
+ const sourceLinks = normalizeSourceLinks(parseDeckPlanSourceLinks(singleFileSubsection(block, "Source Links")))
615
+ const narrativeLinks = parseDeckPlanNarrativeLinks(singleFileSubsection(block, "Narrative Links") || block, knownNodeIds)
616
+ const links = sourceLinksToNarrativeLinks(sourceLinks, narrativeLinks)
617
+ const caveats = uniqueStrings([...parseBulletText(singleFileSubsection(block, "Caveats")), ...sourceLinks.caveats])
618
+ const componentPlan = parseDeckPlanComponentPlan(singleFileSubsection(block, "Component Plan"))
619
+ slides.push({
620
+ path,
621
+ absolutePath,
622
+ id,
623
+ slideIndex,
624
+ title,
625
+ chapter: fields.chapter || "",
626
+ layout: fields.layout || "",
627
+ components: parseCsv(fields.components || componentPlan.map((component) => component.name).join(", ")),
628
+ componentPlan,
629
+ structural: fields.structural === "true" || fields.structural === "yes",
630
+ narrativeRole: fields.role || fields.narrativeRole || "",
631
+ markdown: block,
632
+ frontmatter: {},
633
+ sections: parseMarkdownSections(block),
634
+ links,
635
+ sourceLinks,
636
+ caveats,
637
+ })
638
+ }
639
+ return slides.sort((a, b) => (a.slideIndex ?? Number.MAX_SAFE_INTEGER) - (b.slideIndex ?? Number.MAX_SAFE_INTEGER))
640
+ }
641
+
642
+ function readDeckPlanSeparatorSlidesFromSingleFile(workspaceRoot: string, absolutePath: string, markdown: string, knownNodeIds?: Set<string>): DeckPlanSlideProjection[] {
643
+ const path = relativePath(workspaceRoot, absolutePath)
644
+ const body = parseVaultFrontmatter(markdown).body
645
+ const slidesHeading = /^[ \t]*##\s+Slides\s*$/mi.exec(body)
646
+ if (!slidesHeading || slidesHeading.index === undefined) return []
647
+ const headingEnd = body.indexOf("\n", slidesHeading.index)
648
+ const slidesStart = headingEnd === -1 ? slidesHeading.index + slidesHeading[0].length : headingEnd + 1
649
+ const rest = body.slice(slidesStart)
650
+ const nextSection = rest.search(/^[ \t]*##\s+(?!#)/m)
651
+ const slidesRegion = nextSection === -1 ? rest : rest.slice(0, nextSection)
652
+ const metadataMatches = [...slidesRegion.matchAll(/^[ \t]*---[ \t]*\n[\s\S]*?\n[ \t]*---[ \t]*(?:\n|$)/gm)]
653
+ const slides: DeckPlanSlideProjection[] = []
654
+ for (let i = 0; i < metadataMatches.length; i++) {
655
+ const match = metadataMatches[i]
656
+ const start = match.index ?? 0
657
+ const next = i + 1 < metadataMatches.length ? metadataMatches[i + 1].index ?? slidesRegion.length : slidesRegion.length
658
+ const block = slidesRegion.slice(start, next).trim()
659
+ const parsed = parseVaultFrontmatter(block)
660
+ const slideIndex = numberField(parsed.frontmatter, "slideIndex")
661
+ const title = stringField(parsed.frontmatter, "title") || firstHeading(parsed.body) || `Slide ${slideIndex ?? i + 1}`
662
+ const fields = parseSlideBlockFields(parsed.body)
663
+ const sourceLinks = normalizeSourceLinks(parseDeckPlanSourceLinks(singleFileSubsection(block, "Source Links")))
664
+ const narrativeLinks = parseDeckPlanNarrativeLinks(singleFileSubsection(block, "Narrative Links") || block, knownNodeIds)
665
+ const links = sourceLinksToNarrativeLinks(sourceLinks, narrativeLinks)
666
+ const componentPlan = parseDeckPlanComponentPlan(singleFileSubsection(block, "Component Plan") || singleFileSubsection(block, "Design Plan"))
667
+ slides.push({
668
+ path,
669
+ absolutePath,
670
+ id: stringField(parsed.frontmatter, "id") || fields.id || `slide-${slugify(title)}`,
671
+ slideIndex,
672
+ title,
673
+ chapter: stringField(parsed.frontmatter, "chapter") || fields.chapter || "",
674
+ layout: stringField(parsed.frontmatter, "layout") || fields.layout || "",
675
+ components: arrayField(parsed.frontmatter, "components").length > 0 ? arrayField(parsed.frontmatter, "components") : parseCsv(fields.components || componentPlan.map((component) => component.name).join(", ")),
676
+ componentPlan,
677
+ structural: booleanField(parsed.frontmatter, "structural", fields.structural === "true" || fields.structural === "yes"),
678
+ narrativeRole: stringField(parsed.frontmatter, "role") || stringField(parsed.frontmatter, "narrativeRole") || fields.role || fields.narrativeRole || "",
679
+ markdown: block,
680
+ frontmatter: parsed.frontmatter,
681
+ sections: parseMarkdownSections(block),
682
+ links,
683
+ sourceLinks,
684
+ caveats: uniqueStrings([...parseBulletText(singleFileSubsection(block, "Caveats")), ...sourceLinks.caveats]),
685
+ })
686
+ }
687
+ return slides.sort((a, b) => (a.slideIndex ?? Number.MAX_SAFE_INTEGER) - (b.slideIndex ?? Number.MAX_SAFE_INTEGER))
688
+ }
689
+
690
+ function firstHeading(markdown: string): string {
691
+ return /^#{1,6}\s+(.+?)\s*$/m.exec(markdown)?.[1]?.trim() ?? ""
692
+ }
693
+
694
+ function parseSlideBlockFields(block: string): Record<string, string> {
695
+ const fields: Record<string, string> = {}
696
+ for (const rawLine of block.split(/\r?\n/)) {
697
+ const match = /^-\s+([A-Za-z][A-Za-z ]+):\s*(.*)$/.exec(rawLine.trim())
698
+ if (!match) continue
699
+ const key = match[1].trim().replace(/\s+/g, "")
700
+ fields[key[0].toLowerCase() + key.slice(1)] = cleanPlanValue(match[2])
701
+ }
702
+ return fields
703
+ }
704
+
705
+ function singleFileSubsection(block: string, heading: string): string {
706
+ const re = new RegExp(`^[ \\t]*####\\s+${heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s*$`, "mi")
707
+ const match = re.exec(block)
708
+ if (!match || match.index === undefined) return ""
709
+ const start = match.index + match[0].length
710
+ const rest = block.slice(start)
711
+ const next = rest.search(/^[ \t]*####\s+/m)
712
+ return (next === -1 ? rest : rest.slice(0, next)).trim()
713
+ }
714
+
381
715
  function parseDeckPlanNarrativeLinks(section: string, knownNodeIds?: Set<string>): DeckPlanNarrativeLink[] {
382
716
  const links: DeckPlanNarrativeLink[] = []
383
717
  let group = ""
@@ -396,8 +730,97 @@ function parseDeckPlanNarrativeLinks(section: string, knownNodeIds?: Set<string>
396
730
  return uniqueLinks(links)
397
731
  }
398
732
 
733
+ function emptySourceLinks(): DeckPlanSourceLinks {
734
+ return { materials: [], findings: [], assets: [], urls: [], caveats: [] }
735
+ }
736
+
737
+ function normalizeSourceLinks(input?: Partial<DeckPlanSourceLinks>): DeckPlanSourceLinks {
738
+ return {
739
+ materials: uniqueStrings(input?.materials ?? []),
740
+ findings: uniqueStrings(input?.findings ?? []),
741
+ assets: uniqueStrings(input?.assets ?? []),
742
+ urls: uniqueStrings(input?.urls ?? []),
743
+ caveats: uniqueStrings(input?.caveats ?? []),
744
+ }
745
+ }
746
+
747
+ function sourceLinksFromNarrativeLinks(input?: DeckPlanSlideUpsertInput["narrativeLinks"]): DeckPlanSourceLinks {
748
+ const links = emptySourceLinks()
749
+ for (const id of input?.evidenceIds ?? []) {
750
+ if (/^https?:\/\//i.test(id)) links.urls.push(id)
751
+ else if (id.startsWith("assets/")) links.assets.push(id)
752
+ else if (id.startsWith("researches/")) links.findings.push(id)
753
+ else if (id.startsWith("materials/") || id.startsWith("sources/")) links.materials.push(id)
754
+ else links.findings.push(id)
755
+ }
756
+ for (const id of input?.claimIds ?? []) {
757
+ if (id.startsWith("researches/")) links.findings.push(id)
758
+ else if (id.startsWith("assets/")) links.assets.push(id)
759
+ else links.materials.push(id)
760
+ }
761
+ links.caveats.push(...(input?.riskIds ?? []), ...(input?.objectionIds ?? []), ...(input?.gapIds ?? []))
762
+ return normalizeSourceLinks(links)
763
+ }
764
+
765
+ function sourceLinksForInput(input: DeckPlanSlideUpsertInput): DeckPlanSourceLinks {
766
+ return normalizeSourceLinks({
767
+ ...sourceLinksFromNarrativeLinks(input.narrativeLinks),
768
+ ...(input.sourceLinks ?? {}),
769
+ caveats: [...(sourceLinksFromNarrativeLinks(input.narrativeLinks).caveats ?? []), ...(input.sourceLinks?.caveats ?? []), ...(input.caveats ?? [])],
770
+ })
771
+ }
772
+
773
+ function parseDeckPlanSourceLinks(section: string): DeckPlanSourceLinks {
774
+ const links = emptySourceLinks()
775
+ let group: keyof DeckPlanSourceLinks | undefined
776
+ for (const rawLine of section.replace(/\r\n/g, "\n").split("\n")) {
777
+ const heading = /^\s*([A-Za-z][A-Za-z\s/-]*):\s*$/.exec(rawLine)
778
+ if (heading) {
779
+ const normalized = heading[1].trim().toLowerCase()
780
+ if (normalized.includes("material")) group = "materials"
781
+ else if (normalized.includes("finding") || normalized.includes("research")) group = "findings"
782
+ else if (normalized.includes("asset") || normalized.includes("media")) group = "assets"
783
+ else if (normalized.includes("url") || normalized.includes("link")) group = "urls"
784
+ else if (normalized.includes("caveat") || normalized.includes("risk") || normalized.includes("gap")) group = "caveats"
785
+ else group = undefined
786
+ continue
787
+ }
788
+ const bullet = rawLine.replace(/^\s*[-*]\s+/, "").trim()
789
+ if (!bullet || bullet.toLowerCase() === "none.") continue
790
+ const wikilink = /\[\[([^\]|]+)(?:\|[^\]]+)?\]\]/.exec(bullet)?.[1]?.trim()
791
+ const value = wikilink || bullet
792
+ if (group) links[group].push(value)
793
+ else if (/^https?:\/\//i.test(value)) links.urls.push(value)
794
+ else if (value.startsWith("assets/")) links.assets.push(value)
795
+ else if (value.startsWith("researches/")) links.findings.push(value)
796
+ else links.materials.push(value)
797
+ }
798
+ return normalizeSourceLinks(links)
799
+ }
800
+
801
+ function sourceLinksToNarrativeLinks(sourceLinks: DeckPlanSourceLinks, compatibility: DeckPlanNarrativeLink[] = []): DeckPlanNarrativeLink[] {
802
+ const links: DeckPlanNarrativeLink[] = []
803
+ for (const id of sourceLinks.materials) links.push({ id, relation: "uses_evidence", group: "materials" })
804
+ for (const id of sourceLinks.findings) links.push({ id, relation: "uses_evidence", group: "findings" })
805
+ for (const id of sourceLinks.assets) links.push({ id, relation: "uses_evidence", group: "assets" })
806
+ for (const id of sourceLinks.urls) links.push({ id, relation: "uses_evidence", group: "urls" })
807
+ for (const id of sourceLinks.caveats) links.push({ id, relation: "mentions_gap", group: "caveats" })
808
+ return uniqueLinks([...links, ...compatibility])
809
+ }
810
+
811
+ function parseBulletText(section: string): string[] {
812
+ return section
813
+ .replace(/\r\n/g, "\n")
814
+ .split("\n")
815
+ .map((line) => line.replace(/^\s*[-*]\s+/, "").trim())
816
+ .filter((line) => line && line.toLowerCase() !== "none.")
817
+ }
818
+
399
819
  function relationForDeckPlanLink(group: string, id: string): DeckPlanNarrativeLink["relation"] {
400
820
  const normalized = group.toLowerCase()
821
+ if (normalized.includes("source") || normalized.includes("material")) return "uses_evidence"
822
+ if (normalized.includes("finding") || normalized.includes("research")) return "uses_evidence"
823
+ if (normalized.includes("asset") || normalized.includes("media")) return "uses_evidence"
401
824
  if (normalized.includes("evidence") || id.startsWith("evidence")) return "uses_evidence"
402
825
  if (normalized.includes("risk") || id.startsWith("risk")) return "addresses_risk"
403
826
  if (normalized.includes("objection") || id.startsWith("objection")) return "answers_objection"
@@ -406,6 +829,8 @@ function relationForDeckPlanLink(group: string, id: string): DeckPlanNarrativeLi
406
829
  }
407
830
 
408
831
  function inferredLinkGroup(id: string, knownNodeIds?: Set<string>): string {
832
+ if (id.startsWith("researches/")) return "findings"
833
+ if (id.startsWith("assets/")) return "assets"
409
834
  if (id.startsWith("evidence")) return "evidence"
410
835
  if (id.startsWith("risk")) return "risk"
411
836
  if (id.startsWith("objection")) return "objection"
@@ -416,7 +841,7 @@ function inferredLinkGroup(id: string, knownNodeIds?: Set<string>): string {
416
841
 
417
842
  function deckPlanIndexDiagnostics(slides: DeckPlanSlideProjection[]): DeckPlanProjectionDiagnostic[] {
418
843
  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." })
844
+ if (slides.length === 0) diagnostics.push({ severity: "warning", code: "deck_plan_slides_missing", message: "deck-plan.md contains no slide blocks." })
420
845
  const seen = new Map<number, DeckPlanSlideProjection>()
421
846
  let previous = 0
422
847
  for (const slide of slides) {
@@ -433,9 +858,53 @@ function deckPlanIndexDiagnostics(slides: DeckPlanSlideProjection[]): DeckPlanPr
433
858
  return diagnostics
434
859
  }
435
860
 
861
+ function buildHtmlWritingBatches(slides: DeckPlanSlideProjection[]): DeckPlanHtmlWritingBatch[] {
862
+ const ordered = slides
863
+ .filter((slide) => Number.isInteger(slide.slideIndex) && (slide.slideIndex ?? 0) > 0)
864
+ .sort((a, b) => (a.slideIndex ?? Number.MAX_SAFE_INTEGER) - (b.slideIndex ?? Number.MAX_SAFE_INTEGER))
865
+ const chapterGroups: Array<{ chapterTitle: string; slideIndexes: number[] }> = []
866
+ for (const slide of ordered) {
867
+ const chapterTitle = slide.chapter || "Unassigned"
868
+ const current = chapterGroups[chapterGroups.length - 1]
869
+ if (current && current.chapterTitle === chapterTitle) current.slideIndexes.push(slide.slideIndex!)
870
+ else chapterGroups.push({ chapterTitle, slideIndexes: [slide.slideIndex!] })
871
+ }
872
+ const batches: DeckPlanHtmlWritingBatch[] = []
873
+ for (const group of chapterGroups) {
874
+ const chunks = chunkNumbers(group.slideIndexes, MAX_HTML_SLIDES_PER_BATCH)
875
+ for (let index = 0; index < chunks.length; index += 1) {
876
+ const chunk = chunks[index]
877
+ const chapterSuffix = chunks.length > 1 ? ` part ${index + 1}` : ""
878
+ const label = batches.length === 0
879
+ ? `Initial shell and ${group.chapterTitle}${chapterSuffix}`
880
+ : `${group.chapterTitle}${chapterSuffix}`
881
+ batches.push({
882
+ label,
883
+ chapterTitle: group.chapterTitle,
884
+ slideIndexes: chunk,
885
+ maxSlides: MAX_HTML_SLIDES_PER_BATCH,
886
+ instructions: batches.length === 0
887
+ ? `Create or update the foundation if needed, then write only slide sections ${formatSlideRange(chunk)}. Do not add or rewrite more than ${MAX_HTML_SLIDES_PER_BATCH} slide sections in this write.`
888
+ : `Patch only slide sections ${formatSlideRange(chunk)}, preserve previously written slides, and keep the file valid after the patch. Do not add or rewrite more than ${MAX_HTML_SLIDES_PER_BATCH} slide sections in this write.`,
889
+ })
890
+ }
891
+ }
892
+ return batches
893
+ }
894
+
895
+ function chunkNumbers(values: number[], size: number): number[][] {
896
+ const chunks: number[][] = []
897
+ for (let index = 0; index < values.length; index += size) chunks.push(values.slice(index, index + size))
898
+ return chunks
899
+ }
900
+
901
+ function htmlWritingInstruction(): string {
902
+ return `Before every HTML write/edit/apply_patch, follow htmlWritingBatches and add or rewrite at most ${MAX_HTML_SLIDES_PER_BATCH} <section class="slide"> blocks. Run Artifact QA after each batch before continuing.`
903
+ }
904
+
436
905
  function slideDiagnostics(slide: DeckPlanSlideProjection, knownNodeIds?: Set<string>): DeckPlanProjectionDiagnostic[] {
437
906
  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 })
907
+ if (!slide.structural && linksCount(slide.sourceLinks) === 0) diagnostics.push({ severity: "warning", code: "slide_source_link_missing", message: `Non-structural deck-plan slide ${slide.id} has no material, finding, asset, or URL source link.`, file: slide.path, nodeId: slide.id })
439
908
  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
909
  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
910
  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 +921,30 @@ function slideDiagnostics(slide: DeckPlanSlideProjection, knownNodeIds?: Set<str
452
921
  return diagnostics
453
922
  }
454
923
 
455
- export function deckPlanDesignDiagnostics(projection: DeckPlanProjection | undefined, inventory: { layouts: string[]; components: string[] }): DeckPlanProjectionDiagnostic[] {
924
+ export function deckPlanDesignDiagnostics(projection: DeckPlanProjection | undefined, inventory: { layouts: string[]; components: string[]; layoutSlots?: Record<string, string[]>; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> }): DeckPlanProjectionDiagnostic[] {
456
925
  if (!projection) return []
457
926
  const layouts = new Set(inventory.layouts)
458
927
  const components = new Set(inventory.components)
459
928
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
460
929
  for (const slide of projection.slides) {
461
930
  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
- }
931
+ for (const component of slide.componentPlan) diagnostics.push(...componentDesignDiagnostics(slide, component, inventory, components))
468
932
  }
469
933
  return diagnostics
470
934
  }
471
935
 
472
- function validateDeckPlanSlideUpsert(input: DeckPlanSlideUpsertInput, options: { designLayouts: string[]; designComponents: string[] }): DeckPlanProjectionDiagnostic[] {
936
+ function componentDesignDiagnostics(slide: DeckPlanSlideProjection, component: DeckPlanSlideComponentPlan, inventory: { layoutSlots?: Record<string, string[]>; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> }, components: Set<string>): DeckPlanProjectionDiagnostic[] {
937
+ const diagnostics: DeckPlanProjectionDiagnostic[] = []
938
+ 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 })
939
+ const allowedSlots = slide.layout ? inventory.layoutSlots?.[slide.layout] : undefined
940
+ 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 })
941
+ const nesting = inventory.componentNesting?.[component.name]
942
+ 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 })
943
+ for (const child of component.children ?? []) diagnostics.push(...componentDesignDiagnostics(slide, child, inventory, components))
944
+ return diagnostics
945
+ }
946
+
947
+ function validateDeckPlanSlideUpsert(input: DeckPlanSlideUpsertInput, options: { designLayouts: string[]; designComponents: string[]; layoutSlots?: Record<string, string[]>; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> }): DeckPlanProjectionDiagnostic[] {
473
948
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
474
949
  const nodeId = input.id?.trim() || `slide-${input.slideIndex}`
475
950
  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 +955,54 @@ function validateDeckPlanSlideUpsert(input: DeckPlanSlideUpsertInput, options: {
480
955
  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
956
  const componentNames = new Set<string>()
482
957
  const positions = new Set<string>()
958
+ const allowedSlots = options.layoutSlots?.[input.layout]
483
959
  for (const component of input.components ?? []) {
960
+ validateComponentInput(component, { nodeId, componentNames, positions, options, allowedSlots, parentName: undefined, topLevel: true, diagnostics })
961
+ }
962
+ const visual = normalizeVisualIntent(input.visualIntent)
963
+ 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))
964
+ const sourceLinks = sourceLinksForInput(input)
965
+ 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, or URL source link.", nodeId })
966
+ return diagnostics
967
+ }
968
+
969
+ function validateComponentInput(component: DeckPlanSlideUpsertComponentInput, context: {
970
+ nodeId: string
971
+ componentNames: Set<string>
972
+ positions: Set<string>
973
+ options: { designComponents: string[]; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> }
974
+ allowedSlots?: string[]
975
+ parentName?: string
976
+ topLevel: boolean
977
+ diagnostics: DeckPlanProjectionDiagnostic[]
978
+ }): void {
484
979
  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))
980
+ if (name) context.componentNames.add(name)
981
+ if (!name) context.diagnostics.push(errorDiagnostic("slide_component_name_missing", "Every component requires name.", context.nodeId))
982
+ 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
983
  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))
984
+ if (!String(component[key] || "").trim()) context.diagnostics.push(errorDiagnostic("slide_component_plan_incomplete", `Component '${name || "unnamed"}' is missing ${key}.`, context.nodeId))
490
985
  }
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))
986
+ 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))
987
+ 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
988
  const positionKey = `${component.slot?.trim() || ""}:${component.position?.trim() || ""}`
493
989
  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)
990
+ if (context.positions.has(positionKey)) context.diagnostics.push(errorDiagnostic("slide_component_position_duplicate", `Duplicate component slot/position '${positionKey}' makes the plan ambiguous.`, context.nodeId))
991
+ context.positions.add(positionKey)
496
992
  }
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
993
+ const children = component.children ?? []
994
+ const nesting = name ? context.options.componentNesting?.[name] : undefined
995
+ 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))
996
+ if (children.length > 0 && nesting?.allowedChildren) {
997
+ for (const child of children) {
998
+ 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))
999
+ }
1000
+ }
1001
+ for (const child of children) validateComponentInput(child, { ...context, parentName: name, topLevel: false })
1002
+ }
1003
+
1004
+ function linksCount(sourceLinks: DeckPlanSourceLinks): number {
1005
+ return sourceLinks.materials.length + sourceLinks.findings.length + sourceLinks.assets.length + sourceLinks.urls.length
502
1006
  }
503
1007
 
504
1008
  function errorDiagnostic(code: string, message: string, nodeId?: string): DeckPlanProjectionDiagnostic {
@@ -508,22 +1012,33 @@ function errorDiagnostic(code: string, message: string, nodeId?: string): DeckPl
508
1012
  function parseDeckPlanComponentPlan(section: string): DeckPlanSlideComponentPlan[] {
509
1013
  const components: DeckPlanSlideComponentPlan[] = []
510
1014
  let current: DeckPlanSlideComponentPlan | undefined
1015
+ let currentChild: DeckPlanSlideComponentPlan | undefined
511
1016
  let capture: "content" | undefined
512
1017
  const flush = () => {
1018
+ if (currentChild && current) {
1019
+ current.children = [...(current.children ?? []), normalizeComponentPlan(currentChild)]
1020
+ currentChild = undefined
1021
+ }
513
1022
  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),
1023
+ ...normalizeComponentPlan(current),
1024
+ children: current.children,
520
1025
  })
521
1026
  }
1027
+ const target = () => currentChild ?? current
1028
+ const startComponent = (name: string, child: boolean) => {
1029
+ if (child && current) {
1030
+ if (currentChild) current.children = [...(current.children ?? []), normalizeComponentPlan(currentChild)]
1031
+ currentChild = blankComponentPlan(name)
1032
+ } else {
1033
+ flush()
1034
+ current = blankComponentPlan(name)
1035
+ currentChild = undefined
1036
+ }
1037
+ }
522
1038
  for (const rawLine of section.replace(/\r\n/g, "\n").split("\n")) {
523
- const heading = /^###\s+(.+?)\s*$/.exec(rawLine)
1039
+ const heading = /^(#{3,6})\s+(.+?)\s*$/.exec(rawLine)
524
1040
  if (heading) {
525
- flush()
526
- current = { name: heading[1].trim(), slot: "", position: "", purpose: "", content: "", claimIds: [], evidenceIds: [], sourceNotes: [], renderNotes: [] }
1041
+ startComponent(heading[2].trim(), heading[1].length > 5)
527
1042
  capture = undefined
528
1043
  continue
529
1044
  }
@@ -534,25 +1049,46 @@ function parseDeckPlanComponentPlan(section: string): DeckPlanSlideComponentPlan
534
1049
  capture = undefined
535
1050
  const key = field[1].toLowerCase()
536
1051
  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
1052
+ const item = target()
1053
+ if (!item) continue
1054
+ if (key === "slot") item.slot = value
1055
+ else if (key === "position") item.position = value
1056
+ else if (key === "placement note") item.placementNote = value
1057
+ else if (key === "purpose") item.purpose = value
541
1058
  else if (key === "content") {
542
- current.content = cleanPlanValue(value)
1059
+ item.content = cleanPlanValue(value)
543
1060
  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)
1061
+ } else if (key === "claim ids") item.claimIds = parseCsv(value)
1062
+ else if (key === "evidence ids") item.evidenceIds = parseCsv(value)
1063
+ else if (key === "source notes") item.sourceNotes = parseListValue(value)
1064
+ else if (key === "render notes") item.renderNotes = parseListValue(value)
548
1065
  continue
549
1066
  }
550
- if (capture === "content" && rawLine.trim()) current.content += `${current.content ? "\n" : ""}${rawLine.replace(/^\s{2}/, "")}`
1067
+ if (capture === "content" && rawLine.trim()) {
1068
+ const item = target()
1069
+ if (item) item.content += `${item.content ? "\n" : ""}${rawLine.replace(/^\s{2}/, "")}`
1070
+ }
551
1071
  }
552
1072
  flush()
553
1073
  return components
554
1074
  }
555
1075
 
1076
+ function blankComponentPlan(name: string): DeckPlanSlideComponentPlan {
1077
+ return { name, slot: "", position: "", purpose: "", content: "", claimIds: [], evidenceIds: [], sourceNotes: [], renderNotes: [] }
1078
+ }
1079
+
1080
+ function normalizeComponentPlan(component: DeckPlanSlideComponentPlan): DeckPlanSlideComponentPlan {
1081
+ return {
1082
+ ...component,
1083
+ content: component.content.trim(),
1084
+ claimIds: uniqueStrings(component.claimIds),
1085
+ evidenceIds: uniqueStrings(component.evidenceIds),
1086
+ sourceNotes: component.sourceNotes.filter(Boolean),
1087
+ renderNotes: component.renderNotes.filter(Boolean),
1088
+ children: component.children?.map(normalizeComponentPlan),
1089
+ }
1090
+ }
1091
+
556
1092
  function renderDeckPlanSlideMarkdown(input: DeckPlanSlideUpsertInput & { id: string }): string {
557
1093
  const components = input.components.map((component) => component.name.trim())
558
1094
  const lines: string[] = []
@@ -595,29 +1131,7 @@ function renderDeckPlanSlideMarkdown(input: DeckPlanSlideUpsertInput & { id: str
595
1131
  lines.push(`- Render notes: ${formatListValue(component.renderNotes)}`)
596
1132
  lines.push("")
597
1133
  }
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("")
615
- lines.push("## Caveats")
616
- lines.push("")
617
- const caveats = input.caveats?.filter((item) => item.trim()) ?? []
618
- if (caveats.length === 0) lines.push("- None.")
619
- else for (const caveat of caveats) lines.push(`- ${caveat.trim()}`)
620
- lines.push("")
1134
+ lines.push(renderSourceLinksMarkdown(sourceLinksForInput(input)).replace(/^####/gm, "##"))
621
1135
  return lines.join("\n")
622
1136
  }
623
1137
 
@@ -650,8 +1164,8 @@ function renderMinimalDeckPlanIndex(narrativeHash: string | undefined, slides: D
650
1164
  lines.push("")
651
1165
  lines.push("## Source Authority")
652
1166
  lines.push("")
653
- lines.push("- Meaning: `revela-narrative/` remains canonical.")
654
- lines.push("- Render planning: `deck-plan/` is an execution projection, not approval state.")
1167
+ lines.push("- Sources: local materials, reviewed findings, workspace assets, URLs, and user intent.")
1168
+ lines.push("- Render planning: `deck-plan.md` is the execution blueprint for HTML deck generation.")
655
1169
  lines.push("")
656
1170
  lines.push("## Audience / Goal / Decision")
657
1171
  lines.push("")
@@ -669,7 +1183,7 @@ function renderMinimalDeckPlanIndex(narrativeHash: string | undefined, slides: D
669
1183
  lines.push("")
670
1184
  lines.push("## Slide Plan")
671
1185
  lines.push("")
672
- if (slides.length === 0) lines.push("- No slide files yet.")
1186
+ if (slides.length === 0) lines.push("- No slide blocks yet.")
673
1187
  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
1188
  lines.push("")
675
1189
  lines.push("## Evidence Trace")
@@ -678,10 +1192,10 @@ function renderMinimalDeckPlanIndex(narrativeHash: string | undefined, slides: D
678
1192
  if (evidenceIds.length === 0) lines.push("- No evidence links planned yet.")
679
1193
  else for (const id of evidenceIds) lines.push(`- [[${id}]]`)
680
1194
  lines.push("")
681
- lines.push("## Boundary / Risk Treatment")
1195
+ lines.push("## Source Limitations")
682
1196
  lines.push("")
683
1197
  const boundaryIds = uniqueStrings(slides.flatMap((slide) => slide.links.filter((link) => link.relation === "addresses_risk" || link.relation === "answers_objection" || link.relation === "mentions_gap").map((link) => link.id)))
684
- if (boundaryIds.length === 0) lines.push("- No risk, objection, or gap links planned yet.")
1198
+ if (boundaryIds.length === 0) lines.push("- No legacy risk, objection, or gap links planned.")
685
1199
  else for (const id of boundaryIds) lines.push(`- [[${id}]]`)
686
1200
  lines.push("")
687
1201
  lines.push("## Chapter Writing Batches")
@@ -725,8 +1239,8 @@ function booleanField(frontmatter: Record<string, string | string[] | boolean>,
725
1239
 
726
1240
  function arrayField(frontmatter: Record<string, string | string[] | boolean>, key: string): string[] {
727
1241
  const value = frontmatter[key]
728
- if (Array.isArray(value)) return value.map((item) => item.trim()).filter(Boolean)
729
- if (typeof value === "string" && value.trim()) return value.split(",").map((item) => item.trim()).filter(Boolean)
1242
+ if (Array.isArray(value)) return value.map((item) => item.trim()).filter((item) => item && item.toLowerCase() !== "none")
1243
+ if (typeof value === "string" && value.trim()) return value.split(",").map((item) => item.trim()).filter((item) => item && item.toLowerCase() !== "none")
730
1244
  return []
731
1245
  }
732
1246
 
@@ -860,8 +1374,8 @@ function renderDeckPlanMarkdown(input: DeckPlanArtifactInput): string {
860
1374
  lines.push("")
861
1375
  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.")
862
1376
  lines.push("- Keep every rendered slide exactly 1920x1080px with no page-level scrollbars or hidden overflow.")
863
- lines.push("- Preserve claim-led chapters, visual intent, evidence ids, source trace, supported scope, unsupported scope, caveats, and strength.")
864
- lines.push("- Generate HTML chapter by chapter; do not draft a full 5+ slide deck in one broad write or patch.")
1377
+ lines.push("- Preserve claim-led chapters, visual intent, evidence ids, source trace, source limitations, unresolved inputs, and user review notes.")
1378
+ lines.push(`- Generate HTML in the listed writing batches; do not add or rewrite more than ${MAX_HTML_SLIDES_PER_BATCH} slide sections in one write or patch.`)
865
1379
  lines.push("")
866
1380
  lines.push("## Chapter Map")
867
1381
  lines.push("")
@@ -879,14 +1393,17 @@ function renderDeckPlanMarkdown(input: DeckPlanArtifactInput): string {
879
1393
  }
880
1394
  lines.push("## Chapter Writing Batches")
881
1395
  lines.push("")
882
- lines.push("Use these batches for HTML generation. Keep the HTML valid after every batch and preserve previously written slides.")
1396
+ lines.push(`Use these batches for HTML generation. Each batch is capped at ${MAX_HTML_SLIDES_PER_BATCH} slide sections. Keep the HTML valid after every batch and preserve previously written slides.`)
883
1397
  lines.push("")
884
1398
  if (input.renderPlan) {
885
- for (const batch of input.renderPlan.chapterWritingBatches) lines.push(`- ${batch.label}: ${batch.chapterTitle}, slides ${formatSlideRange(batch.slideIndexes)}. ${batch.instructions}`)
1399
+ for (const batch of input.renderPlan.chapterWritingBatches) lines.push(`- ${batch.label}: ${batch.chapterTitle}, slides ${formatSlideRange(batch.slideIndexes)}; max ${batch.maxSlides} slides. ${batch.instructions}`)
886
1400
  } else {
887
1401
  input.chapters.forEach((chapter, index) => {
888
1402
  const prefix = index === 0 ? "Initial shell and first chapter" : `Chapter batch ${index + 1}`
889
- lines.push(`- ${prefix}: ${chapter.title}, slides ${formatSlideRange(chapter.slideIndexes)}.`)
1403
+ for (const [chunkIndex, chunk] of chunkNumbers(chapter.slideIndexes, MAX_HTML_SLIDES_PER_BATCH).entries()) {
1404
+ const suffix = chunkIndex === 0 ? "" : ` part ${chunkIndex + 1}`
1405
+ lines.push(`- ${prefix}${suffix}: ${chapter.title}, slides ${formatSlideRange(chunk)}; max ${MAX_HTML_SLIDES_PER_BATCH} slides.`)
1406
+ }
890
1407
  })
891
1408
  }
892
1409
  if (input.renderPlan) {