@cyber-dash-tech/revela 0.17.23 → 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 +849 -19
  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 +52 -7
  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 +118 -58
  26. package/plugins/revela/skills/revela-design/SKILL.md +9 -1
  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 -34
  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
@@ -1,4 +1,4 @@
1
- import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs"
1
+ import { existsSync, mkdirSync, readFileSync, readdirSync, rmSync, statSync, writeFileSync } from "fs"
2
2
  import { dirname, join, relative } from "path"
3
3
  import { createHash } from "crypto"
4
4
  import type { DeckSpec, SlideSpec } from "../decks-state"
@@ -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 }>
@@ -81,12 +83,88 @@ export interface DeckPlanSlideProjection {
81
83
  chapter: string
82
84
  layout: string
83
85
  components: string[]
86
+ componentPlan: DeckPlanSlideComponentPlan[]
84
87
  structural: boolean
85
88
  narrativeRole: string
86
89
  markdown: string
87
90
  frontmatter: Record<string, string | string[] | boolean>
88
91
  sections: string[]
89
92
  links: DeckPlanNarrativeLink[]
93
+ sourceLinks: DeckPlanSourceLinks
94
+ caveats: string[]
95
+ }
96
+
97
+ export interface DeckPlanSlideComponentPlan {
98
+ name: string
99
+ slot: string
100
+ position: string
101
+ purpose: string
102
+ content: string
103
+ claimIds: string[]
104
+ evidenceIds: string[]
105
+ sourceNotes: string[]
106
+ renderNotes: string[]
107
+ placementNote?: string
108
+ children?: DeckPlanSlideComponentPlan[]
109
+ }
110
+
111
+ export interface DeckPlanSlideUpsertComponentInput {
112
+ name: string
113
+ slot: string
114
+ position: string
115
+ purpose: string
116
+ content: string
117
+ claimIds?: string[]
118
+ evidenceIds?: string[]
119
+ sourceNotes?: string[]
120
+ renderNotes?: string[]
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[]
131
+ }
132
+
133
+ export interface DeckPlanSlideUpsertInput {
134
+ designName?: string
135
+ outputPath?: string
136
+ slideIndex: number
137
+ id?: string
138
+ title: string
139
+ chapter: string
140
+ narrativeRole: string
141
+ structural?: boolean
142
+ layout: string
143
+ components: DeckPlanSlideUpsertComponentInput[]
144
+ visualIntent: {
145
+ kind?: string
146
+ component?: string
147
+ rationale?: string
148
+ brief?: string
149
+ } | string
150
+ sourceLinks?: Partial<DeckPlanSourceLinks>
151
+ narrativeLinks?: {
152
+ claimIds?: string[]
153
+ evidenceIds?: string[]
154
+ riskIds?: string[]
155
+ objectionIds?: string[]
156
+ gapIds?: string[]
157
+ }
158
+ caveats?: string[]
159
+ }
160
+
161
+ export interface DeckPlanSlideUpsertResult {
162
+ ok: boolean
163
+ path?: string
164
+ absolutePath?: string
165
+ updated?: boolean
166
+ slide?: DeckPlanSlideProjection
167
+ diagnostics: DeckPlanProjectionDiagnostic[]
90
168
  }
91
169
 
92
170
  export interface DeckPlanNarrativeLink {
@@ -104,15 +182,14 @@ export interface DeckPlanProjectionDiagnostic {
104
182
  }
105
183
 
106
184
  export const REQUIRED_DECK_PLAN_SECTIONS = [
185
+ "Goal",
186
+ "Audience",
187
+ "Design",
107
188
  "Source Authority",
108
- "Audience / Goal / Decision",
109
- "Deck Parameters",
110
189
  "Chapter Map",
111
- "Slide Plan",
112
- "Evidence Trace",
113
- "Boundary / Risk Treatment",
114
- "Chapter Writing Batches",
115
- "HTML Identity Contract",
190
+ "Slides",
191
+ "Unresolved Inputs",
192
+ "HTML Contract",
116
193
  ]
117
194
 
118
195
  export function writeDeckPlanArtifact(workspaceRoot: string, input: DeckPlanArtifactInput): { path: string; absolutePath: string } {
@@ -124,17 +201,17 @@ export function writeDeckPlanArtifact(workspaceRoot: string, input: DeckPlanArti
124
201
 
125
202
  export function readDeckPlanArtifact(workspaceRoot: string, expected?: { narrativeHash?: string; knownNodeIds?: Set<string> }): DeckPlanReadResult {
126
203
  const projection = readDeckPlanProjection(workspaceRoot, expected)
127
- const absolutePath = projection?.absolutePath ?? join(workspaceRoot, DECK_PLAN_INDEX_PATH)
204
+ const absolutePath = projection?.absolutePath ?? join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH)
128
205
  if (!existsSync(absolutePath)) {
129
206
  return {
130
207
  ok: false,
131
- path: DECK_PLAN_INDEX_PATH,
208
+ path: DECK_PLAN_MARKDOWN_PATH,
132
209
  absolutePath,
133
210
  approvalStatus: "missing",
134
211
  sections: [],
135
212
  missingSections: REQUIRED_DECK_PLAN_SECTIONS,
136
213
  warnings: [],
137
- 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.`,
138
215
  }
139
216
  }
140
217
  const markdown = projection?.markdown ?? readFileSync(absolutePath, "utf-8")
@@ -160,7 +237,7 @@ export function readDeckPlanArtifact(workspaceRoot: string, expected?: { narrati
160
237
  }
161
238
  return {
162
239
  ok: true,
163
- path: projection?.path ?? DECK_PLAN_INDEX_PATH,
240
+ path: projection?.path ?? DECK_PLAN_MARKDOWN_PATH,
164
241
  absolutePath,
165
242
  markdown,
166
243
  planHash,
@@ -175,9 +252,10 @@ export function readDeckPlanArtifact(workspaceRoot: string, expected?: { narrati
175
252
 
176
253
  export function readDeckPlanProjection(workspaceRoot: string, expected?: { narrativeHash?: string; knownNodeIds?: Set<string> }): DeckPlanProjection | undefined {
177
254
  const root = join(workspaceRoot, DECK_PLAN_DIR)
255
+ const singlePath = join(workspaceRoot, DECK_PLAN_MARKDOWN_PATH)
178
256
  const indexPath = join(workspaceRoot, DECK_PLAN_INDEX_PATH)
179
257
  const legacyPath = join(workspaceRoot, LEGACY_DECK_PLAN_ARTIFACT_PATH)
180
- const absolutePath = existsSync(indexPath) ? indexPath : existsSync(legacyPath) ? legacyPath : ""
258
+ const absolutePath = existsSync(singlePath) ? singlePath : existsSync(indexPath) ? indexPath : existsSync(legacyPath) ? legacyPath : ""
181
259
  if (!absolutePath) return undefined
182
260
  const markdown = readFileSync(absolutePath, "utf-8")
183
261
  const parsed = parseVaultFrontmatter(markdown)
@@ -185,7 +263,8 @@ export function readDeckPlanProjection(workspaceRoot: string, expected?: { narra
185
263
  const sections = parseMarkdownSections(markdown)
186
264
  const path = relativePath(workspaceRoot, absolutePath)
187
265
  const id = stringField(parsed.frontmatter, "id") || "deck-plan"
188
- 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) : []
189
268
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
190
269
  const narrativeHash = stringField(parsed.frontmatter, "narrativeHash") || narrativeHashFromMarkdown(markdown)
191
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 })
@@ -212,7 +291,8 @@ export function readDeckPlanProjection(workspaceRoot: string, expected?: { narra
212
291
  frontmatter: parsed.frontmatter,
213
292
  sections,
214
293
  narrativeHash,
215
- 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"),
216
296
  slides,
217
297
  graphNodes,
218
298
  graphRelations,
@@ -247,6 +327,214 @@ export function deckPlanBodyHash(markdown: string): string {
247
327
  return createHash("sha1").update(stripApprovalSection(markdown).trim()).digest("hex")
248
328
  }
249
329
 
330
+ export function upsertDeckPlanSlideArtifact(
331
+ workspaceRoot: string,
332
+ input: DeckPlanSlideUpsertInput,
333
+ options: { narrativeHash?: string; knownNodeIds?: Set<string>; designLayouts: string[]; designComponents: string[]; layoutSlots?: Record<string, string[]>; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> },
334
+ ): DeckPlanSlideUpsertResult {
335
+ const diagnostics = validateDeckPlanSlideUpsert(input, options)
336
+ if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) return { ok: false, diagnostics }
337
+
338
+ const existing = readDeckPlanProjection(workspaceRoot, { narrativeHash: options.narrativeHash, knownNodeIds: options.knownNodeIds })
339
+ const existingSlide = existing?.slides.find((slide) => slide.slideIndex === input.slideIndex)
340
+ const id = input.id?.trim() || existingSlide?.id || `slide-${slugify(input.title)}`
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
+ })
352
+ const projection = readDeckPlanProjection(workspaceRoot, { narrativeHash: options.narrativeHash, knownNodeIds: options.knownNodeIds })
353
+ const slide = projection?.slides.find((item) => item.slideIndex === input.slideIndex)
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()
536
+ }
537
+
250
538
  function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string>): DeckPlanSlideProjection[] {
251
539
  const slidesDir = join(workspaceRoot, DECK_PLAN_SLIDES_DIR)
252
540
  if (!existsSync(slidesDir) || !statSync(slidesDir).isDirectory()) return []
@@ -259,7 +547,10 @@ function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string
259
547
  const split = splitMarkdownSections(parsed.body)
260
548
  const path = relativePath(workspaceRoot, absolutePath)
261
549
  const id = stringField(parsed.frontmatter, "id") || fileId(entry)
262
- const links = parseDeckPlanNarrativeLinks(split.sections["narrative-links"] ?? parsed.body, knownNodeIds)
550
+ const componentPlan = parseDeckPlanComponentPlan(split.sections["component-plan"] ?? "")
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"] ?? "")
263
554
  slides.push({
264
555
  path,
265
556
  absolutePath,
@@ -269,17 +560,87 @@ function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string
269
560
  chapter: stringField(parsed.frontmatter, "chapter"),
270
561
  layout: stringField(parsed.frontmatter, "layout"),
271
562
  components: arrayField(parsed.frontmatter, "components"),
563
+ componentPlan,
272
564
  structural: booleanField(parsed.frontmatter, "structural", false),
273
565
  narrativeRole: stringField(parsed.frontmatter, "narrativeRole"),
274
566
  markdown,
275
567
  frontmatter: parsed.frontmatter,
276
568
  sections: parseMarkdownSections(markdown),
277
569
  links,
570
+ sourceLinks,
571
+ caveats,
278
572
  })
279
573
  }
280
574
  return slides.sort((a, b) => (a.slideIndex ?? Number.MAX_SAFE_INTEGER) - (b.slideIndex ?? Number.MAX_SAFE_INTEGER) || a.path.localeCompare(b.path))
281
575
  }
282
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
+
283
644
  function parseDeckPlanNarrativeLinks(section: string, knownNodeIds?: Set<string>): DeckPlanNarrativeLink[] {
284
645
  const links: DeckPlanNarrativeLink[] = []
285
646
  let group = ""
@@ -298,8 +659,97 @@ function parseDeckPlanNarrativeLinks(section: string, knownNodeIds?: Set<string>
298
659
  return uniqueLinks(links)
299
660
  }
300
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
+
301
748
  function relationForDeckPlanLink(group: string, id: string): DeckPlanNarrativeLink["relation"] {
302
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"
303
753
  if (normalized.includes("evidence") || id.startsWith("evidence")) return "uses_evidence"
304
754
  if (normalized.includes("risk") || id.startsWith("risk")) return "addresses_risk"
305
755
  if (normalized.includes("objection") || id.startsWith("objection")) return "answers_objection"
@@ -308,6 +758,8 @@ function relationForDeckPlanLink(group: string, id: string): DeckPlanNarrativeLi
308
758
  }
309
759
 
310
760
  function inferredLinkGroup(id: string, knownNodeIds?: Set<string>): string {
761
+ if (id.startsWith("researches/")) return "findings"
762
+ if (id.startsWith("assets/")) return "assets"
311
763
  if (id.startsWith("evidence")) return "evidence"
312
764
  if (id.startsWith("risk")) return "risk"
313
765
  if (id.startsWith("objection")) return "objection"
@@ -318,7 +770,7 @@ function inferredLinkGroup(id: string, knownNodeIds?: Set<string>): string {
318
770
 
319
771
  function deckPlanIndexDiagnostics(slides: DeckPlanSlideProjection[]): DeckPlanProjectionDiagnostic[] {
320
772
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
321
- if (slides.length === 0) diagnostics.push({ severity: "warning", code: "deck_plan_slides_missing", message: "deck-plan/slides contains no slide plan Markdown files." })
773
+ if (slides.length === 0) diagnostics.push({ severity: "warning", code: "deck_plan_slides_missing", message: "deck-plan.md contains no slide blocks." })
322
774
  const seen = new Map<number, DeckPlanSlideProjection>()
323
775
  let previous = 0
324
776
  for (const slide of slides) {
@@ -337,7 +789,15 @@ function deckPlanIndexDiagnostics(slides: DeckPlanSlideProjection[]): DeckPlanPr
337
789
 
338
790
  function slideDiagnostics(slide: DeckPlanSlideProjection, knownNodeIds?: Set<string>): DeckPlanProjectionDiagnostic[] {
339
791
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
340
- if (!slide.structural && !slide.links.some((link) => link.relation === "uses_claim")) diagnostics.push({ severity: "warning", code: "slide_claim_link_missing", message: `Non-structural deck-plan slide ${slide.id} has no claim wikilink in ## Narrative Links.`, file: slide.path, nodeId: slide.id })
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 })
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 })
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 })
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 })
796
+ for (const component of slide.componentPlan) {
797
+ for (const key of ["name", "slot", "position", "purpose", "content"] as const) {
798
+ if (!component[key] || (Array.isArray(component[key]) && component[key].length === 0)) diagnostics.push({ severity: "warning", code: "slide_component_plan_incomplete", message: `Deck-plan slide ${slide.id} has incomplete component plan entry for ${component.name || "unnamed component"}: missing ${key}.`, file: slide.path, nodeId: slide.id })
799
+ }
800
+ }
341
801
  if (knownNodeIds) {
342
802
  for (const link of slide.links) {
343
803
  if (!knownNodeIds.has(link.id)) diagnostics.push({ severity: "warning", code: "deck_plan_broken_link", message: `Deck-plan slide ${slide.id} links to unknown narrative node ${link.id}.`, file: slide.path, nodeId: slide.id })
@@ -346,6 +806,302 @@ function slideDiagnostics(slide: DeckPlanSlideProjection, knownNodeIds?: Set<str
346
806
  return diagnostics
347
807
  }
348
808
 
809
+ export function deckPlanDesignDiagnostics(projection: DeckPlanProjection | undefined, inventory: { layouts: string[]; components: string[]; layoutSlots?: Record<string, string[]>; componentNesting?: Record<string, { acceptsChildren: boolean; allowedChildren?: string[] }> }): DeckPlanProjectionDiagnostic[] {
810
+ if (!projection) return []
811
+ const layouts = new Set(inventory.layouts)
812
+ const components = new Set(inventory.components)
813
+ const diagnostics: DeckPlanProjectionDiagnostic[] = []
814
+ for (const slide of projection.slides) {
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 })
816
+ for (const component of slide.componentPlan) diagnostics.push(...componentDesignDiagnostics(slide, component, inventory, components))
817
+ }
818
+ return diagnostics
819
+ }
820
+
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[] {
833
+ const diagnostics: DeckPlanProjectionDiagnostic[] = []
834
+ const nodeId = input.id?.trim() || `slide-${input.slideIndex}`
835
+ if (!Number.isInteger(input.slideIndex) || input.slideIndex < 1) diagnostics.push(errorDiagnostic("slide_index_invalid", "slideIndex must be a positive 1-based integer.", nodeId))
836
+ for (const [key, value] of [["title", input.title], ["chapter", input.chapter], ["narrativeRole", input.narrativeRole], ["layout", input.layout]] as const) {
837
+ if (!String(value || "").trim()) diagnostics.push(errorDiagnostic(`slide_${key}_missing`, `${key} is required.`, nodeId))
838
+ }
839
+ if (!options.designLayouts.includes(input.layout)) diagnostics.push(errorDiagnostic("slide_layout_unknown", `Layout '${input.layout}' is not in the selected design inventory.`, nodeId))
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))
841
+ const componentNames = new Set<string>()
842
+ const positions = new Set<string>()
843
+ const allowedSlots = options.layoutSlots?.[input.layout]
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 {
864
+ const name = component.name?.trim()
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))
868
+ for (const key of ["slot", "position", "purpose", "content"] as const) {
869
+ if (!String(component[key] || "").trim()) context.diagnostics.push(errorDiagnostic("slide_component_plan_incomplete", `Component '${name || "unnamed"}' is missing ${key}.`, context.nodeId))
870
+ }
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))
873
+ const positionKey = `${component.slot?.trim() || ""}:${component.position?.trim() || ""}`
874
+ if (component.slot?.trim() && component.position?.trim()) {
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)
877
+ }
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
891
+ }
892
+
893
+ function errorDiagnostic(code: string, message: string, nodeId?: string): DeckPlanProjectionDiagnostic {
894
+ return { severity: "error", code, message, nodeId }
895
+ }
896
+
897
+ function parseDeckPlanComponentPlan(section: string): DeckPlanSlideComponentPlan[] {
898
+ const components: DeckPlanSlideComponentPlan[] = []
899
+ let current: DeckPlanSlideComponentPlan | undefined
900
+ let currentChild: DeckPlanSlideComponentPlan | undefined
901
+ let capture: "content" | undefined
902
+ const flush = () => {
903
+ if (currentChild && current) {
904
+ current.children = [...(current.children ?? []), normalizeComponentPlan(currentChild)]
905
+ currentChild = undefined
906
+ }
907
+ if (current) components.push({
908
+ ...normalizeComponentPlan(current),
909
+ children: current.children,
910
+ })
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
+ }
923
+ for (const rawLine of section.replace(/\r\n/g, "\n").split("\n")) {
924
+ const heading = /^(#{3,6})\s+(.+?)\s*$/.exec(rawLine)
925
+ if (heading) {
926
+ startComponent(heading[2].trim(), heading[1].length > 5)
927
+ capture = undefined
928
+ continue
929
+ }
930
+ if (!current) continue
931
+ const line = rawLine.trim()
932
+ const field = /^-\s+([A-Za-z][A-Za-z ]+):\s*(.*)$/.exec(line)
933
+ if (field) {
934
+ capture = undefined
935
+ const key = field[1].toLowerCase()
936
+ const value = field[2].trim()
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
943
+ else if (key === "content") {
944
+ item.content = cleanPlanValue(value)
945
+ capture = value ? undefined : "content"
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)
950
+ continue
951
+ }
952
+ if (capture === "content" && rawLine.trim()) {
953
+ const item = target()
954
+ if (item) item.content += `${item.content ? "\n" : ""}${rawLine.replace(/^\s{2}/, "")}`
955
+ }
956
+ }
957
+ flush()
958
+ return components
959
+ }
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
+
977
+ function renderDeckPlanSlideMarkdown(input: DeckPlanSlideUpsertInput & { id: string }): string {
978
+ const components = input.components.map((component) => component.name.trim())
979
+ const lines: string[] = []
980
+ lines.push("---")
981
+ lines.push("type: deck-plan-slide")
982
+ lines.push(`id: ${input.id}`)
983
+ lines.push(`slideIndex: ${input.slideIndex}`)
984
+ lines.push(`title: ${yamlScalar(input.title)}`)
985
+ lines.push(`chapter: ${yamlScalar(input.chapter)}`)
986
+ lines.push(`layout: ${input.layout.trim()}`)
987
+ lines.push(`components: [${components.map(yamlScalar).join(", ")}]`)
988
+ lines.push(`structural: ${input.structural ? "true" : "false"}`)
989
+ lines.push(`narrativeRole: ${yamlScalar(input.narrativeRole)}`)
990
+ lines.push("---")
991
+ lines.push("")
992
+ lines.push(`# ${input.title.trim()}`)
993
+ lines.push("")
994
+ lines.push("## Purpose")
995
+ lines.push("")
996
+ lines.push(input.narrativeRole.trim())
997
+ lines.push("")
998
+ lines.push("## Visual Intent")
999
+ lines.push("")
1000
+ lines.push(renderVisualIntent(input.visualIntent))
1001
+ lines.push("")
1002
+ lines.push("## Component Plan")
1003
+ lines.push("")
1004
+ for (const component of input.components) {
1005
+ lines.push(`### ${component.name.trim()}`)
1006
+ lines.push("")
1007
+ lines.push(`- Slot: ${component.slot.trim()}`)
1008
+ lines.push(`- Position: ${component.position.trim()}`)
1009
+ if (component.placementNote?.trim()) lines.push(`- Placement note: ${component.placementNote.trim()}`)
1010
+ lines.push(`- Purpose: ${component.purpose.trim()}`)
1011
+ lines.push("- Content:")
1012
+ lines.push(...indentMultiline(component.content.trim()))
1013
+ lines.push(`- Claim ids: ${formatCsv(component.claimIds)}`)
1014
+ lines.push(`- Evidence ids: ${formatCsv(component.evidenceIds)}`)
1015
+ lines.push(`- Source notes: ${formatListValue(component.sourceNotes)}`)
1016
+ lines.push(`- Render notes: ${formatListValue(component.renderNotes)}`)
1017
+ lines.push("")
1018
+ }
1019
+ lines.push(renderSourceLinksMarkdown(sourceLinksForInput(input)).replace(/^####/gm, "##"))
1020
+ lines.push("## Caveats")
1021
+ lines.push("")
1022
+ const caveats = input.caveats?.filter((item) => item.trim()) ?? []
1023
+ if (caveats.length === 0) lines.push("- None.")
1024
+ else for (const caveat of caveats) lines.push(`- ${caveat.trim()}`)
1025
+ lines.push("")
1026
+ return lines.join("\n")
1027
+ }
1028
+
1029
+ function ensureDeckPlanIndex(workspaceRoot: string, narrativeHash?: string): void {
1030
+ const absolutePath = join(workspaceRoot, DECK_PLAN_INDEX_PATH)
1031
+ if (existsSync(absolutePath)) return
1032
+ mkdirSync(dirname(absolutePath), { recursive: true })
1033
+ writeFileSync(absolutePath, renderMinimalDeckPlanIndex(narrativeHash, []), "utf-8")
1034
+ }
1035
+
1036
+ function updateDeckPlanIndex(workspaceRoot: string, narrativeHash?: string): void {
1037
+ const projection = readDeckPlanProjection(workspaceRoot, { narrativeHash })
1038
+ const slides = projection?.slides ?? []
1039
+ writeFileSync(join(workspaceRoot, DECK_PLAN_INDEX_PATH), renderMinimalDeckPlanIndex(narrativeHash || projection?.narrativeHash, slides), "utf-8")
1040
+ }
1041
+
1042
+ function renderMinimalDeckPlanIndex(narrativeHash: string | undefined, slides: DeckPlanSlideProjection[]): string {
1043
+ const chapterMap = new Map<string, number[]>()
1044
+ for (const slide of slides) {
1045
+ const chapter = slide.chapter || "Unassigned"
1046
+ chapterMap.set(chapter, [...(chapterMap.get(chapter) ?? []), slide.slideIndex ?? 0].filter(Boolean))
1047
+ }
1048
+ const lines: string[] = []
1049
+ lines.push("---")
1050
+ lines.push("id: deck-plan")
1051
+ if (narrativeHash) lines.push(`narrativeHash: ${narrativeHash}`)
1052
+ lines.push("---")
1053
+ lines.push("")
1054
+ lines.push("# Deck Plan")
1055
+ lines.push("")
1056
+ lines.push("## Source Authority")
1057
+ lines.push("")
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.")
1060
+ lines.push("")
1061
+ lines.push("## Audience / Goal / Decision")
1062
+ lines.push("")
1063
+ lines.push("- To be specified by narrative state and user intent.")
1064
+ lines.push("")
1065
+ lines.push("## Deck Parameters")
1066
+ lines.push("")
1067
+ lines.push(`- Slide count: ${slides.length}`)
1068
+ if (narrativeHash) lines.push(`- Narrative hash: \`${narrativeHash}\``)
1069
+ lines.push("")
1070
+ lines.push("## Chapter Map")
1071
+ lines.push("")
1072
+ if (chapterMap.size === 0) lines.push("- No slides planned yet.")
1073
+ else for (const [chapter, indexes] of chapterMap) lines.push(`- ${chapter}: slides ${formatSlideRange(indexes)}`)
1074
+ lines.push("")
1075
+ lines.push("## Slide Plan")
1076
+ lines.push("")
1077
+ if (slides.length === 0) lines.push("- No slide blocks yet.")
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"}.`)
1079
+ lines.push("")
1080
+ lines.push("## Evidence Trace")
1081
+ lines.push("")
1082
+ const evidenceIds = uniqueStrings(slides.flatMap((slide) => slide.links.filter((link) => link.relation === "uses_evidence").map((link) => link.id)))
1083
+ if (evidenceIds.length === 0) lines.push("- No evidence links planned yet.")
1084
+ else for (const id of evidenceIds) lines.push(`- [[${id}]]`)
1085
+ lines.push("")
1086
+ lines.push("## Boundary / Risk Treatment")
1087
+ lines.push("")
1088
+ 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)))
1089
+ if (boundaryIds.length === 0) lines.push("- No risk, objection, or gap links planned yet.")
1090
+ else for (const id of boundaryIds) lines.push(`- [[${id}]]`)
1091
+ lines.push("")
1092
+ lines.push("## Chapter Writing Batches")
1093
+ lines.push("")
1094
+ if (chapterMap.size === 0) lines.push("- No chapter batches yet.")
1095
+ else for (const [chapter, indexes] of chapterMap) lines.push(`- ${chapter}: slides ${formatSlideRange(indexes)}.`)
1096
+ lines.push("")
1097
+ lines.push("## HTML Identity Contract")
1098
+ lines.push("")
1099
+ lines.push("- Render one `<section class=\"slide\" data-slide-index=\"N\">` per planned slide.")
1100
+ lines.push("- Use positive 1-based slide indexes, unique indexes, DOM order, and one direct `.slide-canvas` child per slide.")
1101
+ lines.push("")
1102
+ return lines.join("\n")
1103
+ }
1104
+
349
1105
  function narrativeHashFromMarkdown(markdown: string): string {
350
1106
  const match = markdown.match(/narrativeHash:\s*`?([^`\s]+)`?/)
351
1107
  return match?.[1]?.trim() ?? ""
@@ -379,6 +1135,80 @@ function arrayField(frontmatter: Record<string, string | string[] | boolean>, ke
379
1135
  return []
380
1136
  }
381
1137
 
1138
+ function normalizeVisualIntent(input: DeckPlanSlideUpsertInput["visualIntent"]): { kind?: string; component?: string; rationale?: string; brief?: string } {
1139
+ if (typeof input === "string") return { brief: input.trim() }
1140
+ return {
1141
+ kind: input.kind?.trim(),
1142
+ component: input.component?.trim(),
1143
+ rationale: input.rationale?.trim(),
1144
+ brief: input.brief?.trim(),
1145
+ }
1146
+ }
1147
+
1148
+ function renderVisualIntent(input: DeckPlanSlideUpsertInput["visualIntent"]): string {
1149
+ const visual = normalizeVisualIntent(input)
1150
+ const lines: string[] = []
1151
+ if (visual.kind) lines.push(`- Kind: ${visual.kind}`)
1152
+ if (visual.component) lines.push(`- Component: ${visual.component}`)
1153
+ if (visual.rationale) lines.push(`- Rationale: ${visual.rationale}`)
1154
+ if (visual.brief) lines.push(`- Brief: ${visual.brief}`)
1155
+ if (lines.length === 0) lines.push("- Brief: Not specified.")
1156
+ return lines.join("\n")
1157
+ }
1158
+
1159
+ function indentMultiline(value: string): string[] {
1160
+ return value.replace(/\r\n/g, "\n").split("\n").map((line) => ` ${line}`)
1161
+ }
1162
+
1163
+ function yamlScalar(value: string): string {
1164
+ const trimmed = value.trim()
1165
+ if (/^[A-Za-z0-9_-]+$/.test(trimmed)) return trimmed
1166
+ return JSON.stringify(trimmed)
1167
+ }
1168
+
1169
+ function slugify(value: string): string {
1170
+ const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
1171
+ return slug || "slide"
1172
+ }
1173
+
1174
+ function formatCsv(value: string[] | undefined): string {
1175
+ const items = uniqueStrings(value ?? [])
1176
+ return items.length > 0 ? items.join(", ") : "none"
1177
+ }
1178
+
1179
+ function parseCsv(value: string): string[] {
1180
+ const cleaned = cleanPlanValue(value)
1181
+ if (!cleaned || cleaned.toLowerCase() === "none") return []
1182
+ return cleaned.split(",").map((item) => item.trim()).filter(Boolean)
1183
+ }
1184
+
1185
+ function formatListValue(value: string[] | undefined): string {
1186
+ const items = (value ?? []).map((item) => item.trim()).filter(Boolean)
1187
+ return items.length > 0 ? items.join(" | ") : "none"
1188
+ }
1189
+
1190
+ function parseListValue(value: string): string[] {
1191
+ const cleaned = cleanPlanValue(value)
1192
+ if (!cleaned || cleaned.toLowerCase() === "none") return []
1193
+ return cleaned.split("|").map((item) => item.trim()).filter(Boolean)
1194
+ }
1195
+
1196
+ function cleanPlanValue(value: string): string {
1197
+ return value.replace(/^`|`$/g, "").trim()
1198
+ }
1199
+
1200
+ function uniqueStrings(values: string[]): string[] {
1201
+ const seen = new Set<string>()
1202
+ const result: string[] = []
1203
+ for (const value of values) {
1204
+ const trimmed = value.trim()
1205
+ if (!trimmed || seen.has(trimmed)) continue
1206
+ seen.add(trimmed)
1207
+ result.push(trimmed)
1208
+ }
1209
+ return result
1210
+ }
1211
+
382
1212
  function titleFromSectionKey(key: string): string {
383
1213
  return key.split("-").map((part) => part ? `${part[0].toUpperCase()}${part.slice(1)}` : part).join(" ")
384
1214
  }