@cyber-dash-tech/revela 0.17.23 → 0.17.24

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.
@@ -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"
@@ -81,6 +81,7 @@ export interface DeckPlanSlideProjection {
81
81
  chapter: string
82
82
  layout: string
83
83
  components: string[]
84
+ componentPlan: DeckPlanSlideComponentPlan[]
84
85
  structural: boolean
85
86
  narrativeRole: string
86
87
  markdown: string
@@ -89,6 +90,66 @@ export interface DeckPlanSlideProjection {
89
90
  links: DeckPlanNarrativeLink[]
90
91
  }
91
92
 
93
+ export interface DeckPlanSlideComponentPlan {
94
+ name: string
95
+ slot: string
96
+ position: string
97
+ purpose: string
98
+ content: string
99
+ claimIds: string[]
100
+ evidenceIds: string[]
101
+ sourceNotes: string[]
102
+ renderNotes: string[]
103
+ placementNote?: string
104
+ }
105
+
106
+ export interface DeckPlanSlideUpsertComponentInput {
107
+ name: string
108
+ slot: string
109
+ position: string
110
+ purpose: string
111
+ content: string
112
+ claimIds?: string[]
113
+ evidenceIds?: string[]
114
+ sourceNotes?: string[]
115
+ renderNotes?: string[]
116
+ placementNote?: string
117
+ }
118
+
119
+ export interface DeckPlanSlideUpsertInput {
120
+ slideIndex: number
121
+ id?: string
122
+ title: string
123
+ chapter: string
124
+ narrativeRole: string
125
+ structural?: boolean
126
+ layout: string
127
+ components: DeckPlanSlideUpsertComponentInput[]
128
+ visualIntent: {
129
+ kind?: string
130
+ component?: string
131
+ rationale?: string
132
+ brief?: string
133
+ } | string
134
+ narrativeLinks: {
135
+ claimIds?: string[]
136
+ evidenceIds?: string[]
137
+ riskIds?: string[]
138
+ objectionIds?: string[]
139
+ gapIds?: string[]
140
+ }
141
+ caveats?: string[]
142
+ }
143
+
144
+ export interface DeckPlanSlideUpsertResult {
145
+ ok: boolean
146
+ path?: string
147
+ absolutePath?: string
148
+ updated?: boolean
149
+ slide?: DeckPlanSlideProjection
150
+ diagnostics: DeckPlanProjectionDiagnostic[]
151
+ }
152
+
92
153
  export interface DeckPlanNarrativeLink {
93
154
  id: string
94
155
  relation: "uses_claim" | "uses_evidence" | "addresses_risk" | "answers_objection" | "mentions_gap"
@@ -247,6 +308,41 @@ export function deckPlanBodyHash(markdown: string): string {
247
308
  return createHash("sha1").update(stripApprovalSection(markdown).trim()).digest("hex")
248
309
  }
249
310
 
311
+ export function upsertDeckPlanSlideArtifact(
312
+ workspaceRoot: string,
313
+ input: DeckPlanSlideUpsertInput,
314
+ options: { narrativeHash?: string; knownNodeIds?: Set<string>; designLayouts: string[]; designComponents: string[] },
315
+ ): DeckPlanSlideUpsertResult {
316
+ const diagnostics = validateDeckPlanSlideUpsert(input, options)
317
+ if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) return { ok: false, diagnostics }
318
+
319
+ mkdirSync(join(workspaceRoot, DECK_PLAN_SLIDES_DIR), { recursive: true })
320
+ ensureDeckPlanIndex(workspaceRoot, options.narrativeHash)
321
+
322
+ const existing = readDeckPlanProjection(workspaceRoot, { narrativeHash: options.narrativeHash, knownNodeIds: options.knownNodeIds })
323
+ const existingSlide = existing?.slides.find((slide) => slide.slideIndex === input.slideIndex)
324
+ const id = input.id?.trim() || existingSlide?.id || `slide-${slugify(input.title)}`
325
+ const filename = `${String(input.slideIndex).padStart(3, "0")}-${slugify(input.title)}.md`
326
+ const path = `${DECK_PLAN_SLIDES_DIR}/${filename}`
327
+ const absolutePath = join(workspaceRoot, path)
328
+ const markdown = renderDeckPlanSlideMarkdown({ ...input, id })
329
+
330
+ writeFileSync(absolutePath, markdown, "utf-8")
331
+ if (existingSlide && existingSlide.absolutePath !== absolutePath && existsSync(existingSlide.absolutePath)) {
332
+ try {
333
+ rmSync(existingSlide.absolutePath)
334
+ } catch {
335
+ // Empty stale files are ignored by readers only when removed; if removal fails,
336
+ // duplicate slideIndex diagnostics will surface on the next read.
337
+ }
338
+ }
339
+
340
+ updateDeckPlanIndex(workspaceRoot, options.narrativeHash)
341
+ const projection = readDeckPlanProjection(workspaceRoot, { narrativeHash: options.narrativeHash, knownNodeIds: options.knownNodeIds })
342
+ const slide = projection?.slides.find((item) => item.slideIndex === input.slideIndex)
343
+ return { ok: true, path, absolutePath, updated: Boolean(existingSlide), slide, diagnostics: [...diagnostics, ...(projection?.diagnostics ?? [])] }
344
+ }
345
+
250
346
  function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string>): DeckPlanSlideProjection[] {
251
347
  const slidesDir = join(workspaceRoot, DECK_PLAN_SLIDES_DIR)
252
348
  if (!existsSync(slidesDir) || !statSync(slidesDir).isDirectory()) return []
@@ -259,6 +355,7 @@ function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string
259
355
  const split = splitMarkdownSections(parsed.body)
260
356
  const path = relativePath(workspaceRoot, absolutePath)
261
357
  const id = stringField(parsed.frontmatter, "id") || fileId(entry)
358
+ const componentPlan = parseDeckPlanComponentPlan(split.sections["component-plan"] ?? "")
262
359
  const links = parseDeckPlanNarrativeLinks(split.sections["narrative-links"] ?? parsed.body, knownNodeIds)
263
360
  slides.push({
264
361
  path,
@@ -269,6 +366,7 @@ function readDeckPlanSlideFiles(workspaceRoot: string, knownNodeIds?: Set<string
269
366
  chapter: stringField(parsed.frontmatter, "chapter"),
270
367
  layout: stringField(parsed.frontmatter, "layout"),
271
368
  components: arrayField(parsed.frontmatter, "components"),
369
+ componentPlan,
272
370
  structural: booleanField(parsed.frontmatter, "structural", false),
273
371
  narrativeRole: stringField(parsed.frontmatter, "narrativeRole"),
274
372
  markdown,
@@ -338,6 +436,14 @@ function deckPlanIndexDiagnostics(slides: DeckPlanSlideProjection[]): DeckPlanPr
338
436
  function slideDiagnostics(slide: DeckPlanSlideProjection, knownNodeIds?: Set<string>): DeckPlanProjectionDiagnostic[] {
339
437
  const diagnostics: DeckPlanProjectionDiagnostic[] = []
340
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 })
439
+ 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
+ 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
+ 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 })
442
+ for (const component of slide.componentPlan) {
443
+ for (const key of ["name", "slot", "position", "purpose", "content"] as const) {
444
+ 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 })
445
+ }
446
+ }
341
447
  if (knownNodeIds) {
342
448
  for (const link of slide.links) {
343
449
  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 +452,251 @@ function slideDiagnostics(slide: DeckPlanSlideProjection, knownNodeIds?: Set<str
346
452
  return diagnostics
347
453
  }
348
454
 
455
+ export function deckPlanDesignDiagnostics(projection: DeckPlanProjection | undefined, inventory: { layouts: string[]; components: string[] }): DeckPlanProjectionDiagnostic[] {
456
+ if (!projection) return []
457
+ const layouts = new Set(inventory.layouts)
458
+ const components = new Set(inventory.components)
459
+ const diagnostics: DeckPlanProjectionDiagnostic[] = []
460
+ for (const slide of projection.slides) {
461
+ 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
+ }
468
+ }
469
+ return diagnostics
470
+ }
471
+
472
+ function validateDeckPlanSlideUpsert(input: DeckPlanSlideUpsertInput, options: { designLayouts: string[]; designComponents: string[] }): DeckPlanProjectionDiagnostic[] {
473
+ const diagnostics: DeckPlanProjectionDiagnostic[] = []
474
+ const nodeId = input.id?.trim() || `slide-${input.slideIndex}`
475
+ if (!Number.isInteger(input.slideIndex) || input.slideIndex < 1) diagnostics.push(errorDiagnostic("slide_index_invalid", "slideIndex must be a positive 1-based integer.", nodeId))
476
+ for (const [key, value] of [["title", input.title], ["chapter", input.chapter], ["narrativeRole", input.narrativeRole], ["layout", input.layout]] as const) {
477
+ if (!String(value || "").trim()) diagnostics.push(errorDiagnostic(`slide_${key}_missing`, `${key} is required.`, nodeId))
478
+ }
479
+ if (!options.designLayouts.includes(input.layout)) diagnostics.push(errorDiagnostic("slide_layout_unknown", `Layout '${input.layout}' is not in the selected design inventory.`, nodeId))
480
+ 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
+ const componentNames = new Set<string>()
482
+ const positions = new Set<string>()
483
+ for (const component of input.components ?? []) {
484
+ 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))
488
+ 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))
490
+ }
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))
492
+ const positionKey = `${component.slot?.trim() || ""}:${component.position?.trim() || ""}`
493
+ 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)
496
+ }
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
502
+ }
503
+
504
+ function errorDiagnostic(code: string, message: string, nodeId?: string): DeckPlanProjectionDiagnostic {
505
+ return { severity: "error", code, message, nodeId }
506
+ }
507
+
508
+ function parseDeckPlanComponentPlan(section: string): DeckPlanSlideComponentPlan[] {
509
+ const components: DeckPlanSlideComponentPlan[] = []
510
+ let current: DeckPlanSlideComponentPlan | undefined
511
+ let capture: "content" | undefined
512
+ const flush = () => {
513
+ 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),
520
+ })
521
+ }
522
+ for (const rawLine of section.replace(/\r\n/g, "\n").split("\n")) {
523
+ const heading = /^###\s+(.+?)\s*$/.exec(rawLine)
524
+ if (heading) {
525
+ flush()
526
+ current = { name: heading[1].trim(), slot: "", position: "", purpose: "", content: "", claimIds: [], evidenceIds: [], sourceNotes: [], renderNotes: [] }
527
+ capture = undefined
528
+ continue
529
+ }
530
+ if (!current) continue
531
+ const line = rawLine.trim()
532
+ const field = /^-\s+([A-Za-z][A-Za-z ]+):\s*(.*)$/.exec(line)
533
+ if (field) {
534
+ capture = undefined
535
+ const key = field[1].toLowerCase()
536
+ 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
541
+ else if (key === "content") {
542
+ current.content = cleanPlanValue(value)
543
+ 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)
548
+ continue
549
+ }
550
+ if (capture === "content" && rawLine.trim()) current.content += `${current.content ? "\n" : ""}${rawLine.replace(/^\s{2}/, "")}`
551
+ }
552
+ flush()
553
+ return components
554
+ }
555
+
556
+ function renderDeckPlanSlideMarkdown(input: DeckPlanSlideUpsertInput & { id: string }): string {
557
+ const components = input.components.map((component) => component.name.trim())
558
+ const lines: string[] = []
559
+ lines.push("---")
560
+ lines.push("type: deck-plan-slide")
561
+ lines.push(`id: ${input.id}`)
562
+ lines.push(`slideIndex: ${input.slideIndex}`)
563
+ lines.push(`title: ${yamlScalar(input.title)}`)
564
+ lines.push(`chapter: ${yamlScalar(input.chapter)}`)
565
+ lines.push(`layout: ${input.layout.trim()}`)
566
+ lines.push(`components: [${components.map(yamlScalar).join(", ")}]`)
567
+ lines.push(`structural: ${input.structural ? "true" : "false"}`)
568
+ lines.push(`narrativeRole: ${yamlScalar(input.narrativeRole)}`)
569
+ lines.push("---")
570
+ lines.push("")
571
+ lines.push(`# ${input.title.trim()}`)
572
+ lines.push("")
573
+ lines.push("## Purpose")
574
+ lines.push("")
575
+ lines.push(input.narrativeRole.trim())
576
+ lines.push("")
577
+ lines.push("## Visual Intent")
578
+ lines.push("")
579
+ lines.push(renderVisualIntent(input.visualIntent))
580
+ lines.push("")
581
+ lines.push("## Component Plan")
582
+ lines.push("")
583
+ for (const component of input.components) {
584
+ lines.push(`### ${component.name.trim()}`)
585
+ lines.push("")
586
+ lines.push(`- Slot: ${component.slot.trim()}`)
587
+ lines.push(`- Position: ${component.position.trim()}`)
588
+ if (component.placementNote?.trim()) lines.push(`- Placement note: ${component.placementNote.trim()}`)
589
+ lines.push(`- Purpose: ${component.purpose.trim()}`)
590
+ lines.push("- Content:")
591
+ lines.push(...indentMultiline(component.content.trim()))
592
+ lines.push(`- Claim ids: ${formatCsv(component.claimIds)}`)
593
+ lines.push(`- Evidence ids: ${formatCsv(component.evidenceIds)}`)
594
+ lines.push(`- Source notes: ${formatListValue(component.sourceNotes)}`)
595
+ lines.push(`- Render notes: ${formatListValue(component.renderNotes)}`)
596
+ lines.push("")
597
+ }
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("")
621
+ return lines.join("\n")
622
+ }
623
+
624
+ function ensureDeckPlanIndex(workspaceRoot: string, narrativeHash?: string): void {
625
+ const absolutePath = join(workspaceRoot, DECK_PLAN_INDEX_PATH)
626
+ if (existsSync(absolutePath)) return
627
+ mkdirSync(dirname(absolutePath), { recursive: true })
628
+ writeFileSync(absolutePath, renderMinimalDeckPlanIndex(narrativeHash, []), "utf-8")
629
+ }
630
+
631
+ function updateDeckPlanIndex(workspaceRoot: string, narrativeHash?: string): void {
632
+ const projection = readDeckPlanProjection(workspaceRoot, { narrativeHash })
633
+ const slides = projection?.slides ?? []
634
+ writeFileSync(join(workspaceRoot, DECK_PLAN_INDEX_PATH), renderMinimalDeckPlanIndex(narrativeHash || projection?.narrativeHash, slides), "utf-8")
635
+ }
636
+
637
+ function renderMinimalDeckPlanIndex(narrativeHash: string | undefined, slides: DeckPlanSlideProjection[]): string {
638
+ const chapterMap = new Map<string, number[]>()
639
+ for (const slide of slides) {
640
+ const chapter = slide.chapter || "Unassigned"
641
+ chapterMap.set(chapter, [...(chapterMap.get(chapter) ?? []), slide.slideIndex ?? 0].filter(Boolean))
642
+ }
643
+ const lines: string[] = []
644
+ lines.push("---")
645
+ lines.push("id: deck-plan")
646
+ if (narrativeHash) lines.push(`narrativeHash: ${narrativeHash}`)
647
+ lines.push("---")
648
+ lines.push("")
649
+ lines.push("# Deck Plan")
650
+ lines.push("")
651
+ lines.push("## Source Authority")
652
+ lines.push("")
653
+ lines.push("- Meaning: `revela-narrative/` remains canonical.")
654
+ lines.push("- Render planning: `deck-plan/` is an execution projection, not approval state.")
655
+ lines.push("")
656
+ lines.push("## Audience / Goal / Decision")
657
+ lines.push("")
658
+ lines.push("- To be specified by narrative state and user intent.")
659
+ lines.push("")
660
+ lines.push("## Deck Parameters")
661
+ lines.push("")
662
+ lines.push(`- Slide count: ${slides.length}`)
663
+ if (narrativeHash) lines.push(`- Narrative hash: \`${narrativeHash}\``)
664
+ lines.push("")
665
+ lines.push("## Chapter Map")
666
+ lines.push("")
667
+ if (chapterMap.size === 0) lines.push("- No slides planned yet.")
668
+ else for (const [chapter, indexes] of chapterMap) lines.push(`- ${chapter}: slides ${formatSlideRange(indexes)}`)
669
+ lines.push("")
670
+ lines.push("## Slide Plan")
671
+ lines.push("")
672
+ if (slides.length === 0) lines.push("- No slide files yet.")
673
+ 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
+ lines.push("")
675
+ lines.push("## Evidence Trace")
676
+ lines.push("")
677
+ const evidenceIds = uniqueStrings(slides.flatMap((slide) => slide.links.filter((link) => link.relation === "uses_evidence").map((link) => link.id)))
678
+ if (evidenceIds.length === 0) lines.push("- No evidence links planned yet.")
679
+ else for (const id of evidenceIds) lines.push(`- [[${id}]]`)
680
+ lines.push("")
681
+ lines.push("## Boundary / Risk Treatment")
682
+ lines.push("")
683
+ 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.")
685
+ else for (const id of boundaryIds) lines.push(`- [[${id}]]`)
686
+ lines.push("")
687
+ lines.push("## Chapter Writing Batches")
688
+ lines.push("")
689
+ if (chapterMap.size === 0) lines.push("- No chapter batches yet.")
690
+ else for (const [chapter, indexes] of chapterMap) lines.push(`- ${chapter}: slides ${formatSlideRange(indexes)}.`)
691
+ lines.push("")
692
+ lines.push("## HTML Identity Contract")
693
+ lines.push("")
694
+ lines.push("- Render one `<section class=\"slide\" data-slide-index=\"N\">` per planned slide.")
695
+ lines.push("- Use positive 1-based slide indexes, unique indexes, DOM order, and one direct `.slide-canvas` child per slide.")
696
+ lines.push("")
697
+ return lines.join("\n")
698
+ }
699
+
349
700
  function narrativeHashFromMarkdown(markdown: string): string {
350
701
  const match = markdown.match(/narrativeHash:\s*`?([^`\s]+)`?/)
351
702
  return match?.[1]?.trim() ?? ""
@@ -379,6 +730,80 @@ function arrayField(frontmatter: Record<string, string | string[] | boolean>, ke
379
730
  return []
380
731
  }
381
732
 
733
+ function normalizeVisualIntent(input: DeckPlanSlideUpsertInput["visualIntent"]): { kind?: string; component?: string; rationale?: string; brief?: string } {
734
+ if (typeof input === "string") return { brief: input.trim() }
735
+ return {
736
+ kind: input.kind?.trim(),
737
+ component: input.component?.trim(),
738
+ rationale: input.rationale?.trim(),
739
+ brief: input.brief?.trim(),
740
+ }
741
+ }
742
+
743
+ function renderVisualIntent(input: DeckPlanSlideUpsertInput["visualIntent"]): string {
744
+ const visual = normalizeVisualIntent(input)
745
+ const lines: string[] = []
746
+ if (visual.kind) lines.push(`- Kind: ${visual.kind}`)
747
+ if (visual.component) lines.push(`- Component: ${visual.component}`)
748
+ if (visual.rationale) lines.push(`- Rationale: ${visual.rationale}`)
749
+ if (visual.brief) lines.push(`- Brief: ${visual.brief}`)
750
+ if (lines.length === 0) lines.push("- Brief: Not specified.")
751
+ return lines.join("\n")
752
+ }
753
+
754
+ function indentMultiline(value: string): string[] {
755
+ return value.replace(/\r\n/g, "\n").split("\n").map((line) => ` ${line}`)
756
+ }
757
+
758
+ function yamlScalar(value: string): string {
759
+ const trimmed = value.trim()
760
+ if (/^[A-Za-z0-9_-]+$/.test(trimmed)) return trimmed
761
+ return JSON.stringify(trimmed)
762
+ }
763
+
764
+ function slugify(value: string): string {
765
+ const slug = value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
766
+ return slug || "slide"
767
+ }
768
+
769
+ function formatCsv(value: string[] | undefined): string {
770
+ const items = uniqueStrings(value ?? [])
771
+ return items.length > 0 ? items.join(", ") : "none"
772
+ }
773
+
774
+ function parseCsv(value: string): string[] {
775
+ const cleaned = cleanPlanValue(value)
776
+ if (!cleaned || cleaned.toLowerCase() === "none") return []
777
+ return cleaned.split(",").map((item) => item.trim()).filter(Boolean)
778
+ }
779
+
780
+ function formatListValue(value: string[] | undefined): string {
781
+ const items = (value ?? []).map((item) => item.trim()).filter(Boolean)
782
+ return items.length > 0 ? items.join(" | ") : "none"
783
+ }
784
+
785
+ function parseListValue(value: string): string[] {
786
+ const cleaned = cleanPlanValue(value)
787
+ if (!cleaned || cleaned.toLowerCase() === "none") return []
788
+ return cleaned.split("|").map((item) => item.trim()).filter(Boolean)
789
+ }
790
+
791
+ function cleanPlanValue(value: string): string {
792
+ return value.replace(/^`|`$/g, "").trim()
793
+ }
794
+
795
+ function uniqueStrings(values: string[]): string[] {
796
+ const seen = new Set<string>()
797
+ const result: string[] = []
798
+ for (const value of values) {
799
+ const trimmed = value.trim()
800
+ if (!trimmed || seen.has(trimmed)) continue
801
+ seen.add(trimmed)
802
+ result.push(trimmed)
803
+ }
804
+ return result
805
+ }
806
+
382
807
  function titleFromSectionKey(key: string): string {
383
808
  return key.split("-").map((part) => part ? `${part[0].toUpperCase()}${part.slice(1)}` : part).join(" ")
384
809
  }
@@ -25,7 +25,7 @@ import { autoCompileNarrativeVault } from "../narrative-vault/auto-compile"
25
25
  import { extractNarrativeVaultMarkdownTargetsFromPatch } from "../narrative-vault/hook-targets"
26
26
  import { runNarrativeMarkdownQa, type MarkdownQaOptions } from "../narrative-vault/markdown-qa"
27
27
  import { formatArtifactQaUserNotice, formatMarkdownQaUserNotice } from "../hook-notifications"
28
- import { readDeckPlanArtifact } from "../narrative-state/deck-plan-artifact"
28
+ import { deckPlanDesignDiagnostics, readDeckPlanArtifact, upsertDeckPlanSlideArtifact, type DeckPlanSlideUpsertInput } from "../narrative-state/deck-plan-artifact"
29
29
  import { extractDesignClasses } from "../design/designs"
30
30
  import { recordRenderedArtifact, workspaceRelative } from "../workspace-state/rendered-artifacts"
31
31
  import { checkMaterialIntake, extractMaterial, materialIntakeNoticeForCommand, prepareLocalMaterials, recordMaterialReview } from "../material-intake"
@@ -76,6 +76,10 @@ export interface RuntimeDesignComponentReadInput {
76
76
  component: string | string[]
77
77
  }
78
78
 
79
+ export interface RuntimeDeckPlanSlideUpsertInput extends RuntimeWorkspaceInput, DeckPlanSlideUpsertInput {
80
+ designName?: string
81
+ }
82
+
79
83
  export interface RuntimeDesignCreateInput {
80
84
  name: string
81
85
  base?: string
@@ -149,10 +153,44 @@ export function readDeckPlan(input: RuntimeWorkspaceInput = {}) {
149
153
  const workspaceRoot = root(input.workspaceRoot)
150
154
  const compiled = compileNarrativeVault(workspaceRoot)
151
155
  const knownNodeIds = compiled.graph ? new Set(compiled.graph.nodes.map((node) => node.id)) : undefined
152
- return readDeckPlanArtifact(workspaceRoot, {
156
+ const read = readDeckPlanArtifact(workspaceRoot, {
153
157
  narrativeHash: compiled.narrative ? computeNarrativeHash(compiled.narrative) : undefined,
154
158
  knownNodeIds,
155
159
  })
160
+ if (read.projection) {
161
+ try {
162
+ const inventory = getDesignInventory(activeDesign())
163
+ const diagnostics = deckPlanDesignDiagnostics(read.projection, {
164
+ layouts: inventory.layouts.map((layout) => layout.name),
165
+ components: inventory.components.map((component) => component.name),
166
+ })
167
+ read.projection.diagnostics.push(...diagnostics)
168
+ read.warnings.push(...diagnostics.map((diagnostic) => diagnostic.message))
169
+ } catch {
170
+ // Design diagnostics are advisory; deck-plan reading remains available.
171
+ }
172
+ }
173
+ return read
174
+ }
175
+
176
+ export function upsertDeckPlanSlide(input: RuntimeDeckPlanSlideUpsertInput) {
177
+ const workspaceRoot = root(input.workspaceRoot)
178
+ const designName = input.designName || activeDesign()
179
+ const inventory = getDesignInventory(designName)
180
+ const compiled = compileNarrativeVault(workspaceRoot)
181
+ const knownNodeIds = compiled.graph ? new Set(compiled.graph.nodes.map((node) => node.id)) : undefined
182
+ const narrativeHash = compiled.narrative ? computeNarrativeHash(compiled.narrative) : undefined
183
+ const result = upsertDeckPlanSlideArtifact(workspaceRoot, input, {
184
+ narrativeHash,
185
+ knownNodeIds,
186
+ designLayouts: inventory.layouts.map((layout) => layout.name),
187
+ designComponents: inventory.components.map((component) => component.name),
188
+ })
189
+ return {
190
+ ...result,
191
+ designName,
192
+ narrativeHash,
193
+ }
156
194
  }
157
195
 
158
196
  export function createDeckFoundation(input: RuntimeDeckFoundationInput) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.17.23",
3
+ "version": "0.17.24",
4
4
  "description": "OpenCode plugin for trusted narrative artifacts from local sources, research, and evidence",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "revela": {
4
4
  "command": "npx",
5
- "args": ["-y", "@cyber-dash-tech/revela@0.17.23", "mcp"]
5
+ "args": ["-y", "@cyber-dash-tech/revela@0.17.24", "mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -13,6 +13,7 @@ type RuntimeModule = {
13
13
  compileNarrative(input?: any): any
14
14
  markdownQa(input?: any): any
15
15
  readDeckPlan(input?: any): any
16
+ upsertDeckPlanSlide(input: any): any
16
17
  createDeckFoundation(input: any): any
17
18
  runDeckQa(input: any): Promise<any>
18
19
  exportPdf(input: any): Promise<any>
@@ -77,6 +78,25 @@ const tools = [
77
78
  description: "Read the file-native deck-plan/ projection and diagnostics.",
78
79
  inputSchema: objectSchema({ workspaceRoot: stringProp("Optional workspace root.") }),
79
80
  },
81
+ {
82
+ name: "revela_upsert_deck_plan_slide",
83
+ description: "Create or update one structured deck-plan/slides/*.md slide plan with active/requested design vocabulary validation.",
84
+ inputSchema: objectSchema({
85
+ workspaceRoot: stringProp("Optional workspace root."),
86
+ designName: stringProp("Optional design name. Defaults to the active design."),
87
+ slideIndex: requiredNumberProp("Positive 1-based slide index."),
88
+ id: stringProp("Optional stable slide node id."),
89
+ title: requiredStringProp("Slide title."),
90
+ chapter: requiredStringProp("Chapter name."),
91
+ narrativeRole: requiredStringProp("Slide's communication job."),
92
+ structural: booleanProp("Whether this is a structural slide such as cover, TOC, divider, or closing."),
93
+ layout: requiredStringProp("Design layout name from inventory."),
94
+ components: componentPlanArrayProp(),
95
+ visualIntent: visualIntentProp(),
96
+ narrativeLinks: narrativeLinksProp(),
97
+ caveats: arrayProp("Optional caveats to keep visible in the slide plan."),
98
+ }, ["slideIndex", "title", "chapter", "narrativeRole", "layout", "components", "visualIntent", "narrativeLinks"]),
99
+ },
80
100
  {
81
101
  name: "revela_create_deck_foundation",
82
102
  description: "Create or repair a file-native Revela HTML deck foundation shell.",
@@ -414,6 +434,7 @@ async function callTool(name: string, args: any): Promise<any> {
414
434
  if (name === "revela_compile_narrative") return r.compileNarrative(args)
415
435
  if (name === "revela_markdown_qa") return r.markdownQa(args)
416
436
  if (name === "revela_read_deck_plan") return r.readDeckPlan(args)
437
+ if (name === "revela_upsert_deck_plan_slide") return r.upsertDeckPlanSlide(args)
417
438
  if (name === "revela_create_deck_foundation") return r.createDeckFoundation(args)
418
439
  if (name === "revela_run_deck_qa") return r.runDeckQa(args)
419
440
  if (name === "revela_export_pdf") return r.exportPdf(args)
@@ -479,6 +500,10 @@ function numberProp(description: string) {
479
500
  return { type: "number", description }
480
501
  }
481
502
 
503
+ function requiredNumberProp(description: string) {
504
+ return { type: "number", description }
505
+ }
506
+
482
507
  function enumProp(values: string[], description: string) {
483
508
  return { type: "string", enum: values, description }
484
509
  }
@@ -514,6 +539,63 @@ function arrayObjectProp(description: string) {
514
539
  }
515
540
  }
516
541
 
542
+ function componentPlanArrayProp() {
543
+ return {
544
+ type: "array",
545
+ description: "Structured component plan entries.",
546
+ items: {
547
+ type: "object",
548
+ properties: {
549
+ name: { type: "string", description: "Design component name." },
550
+ slot: { type: "string", description: "Layout semantic slot, such as left, right, top, main, bottom, footer, or fullbleed." },
551
+ position: { type: "string", description: "Slot-local kebab-case anchor, such as left-top, center, or overlay-top-right." },
552
+ purpose: { type: "string", description: "Component communication job." },
553
+ content: { type: "string", description: "Exact text, data, or structure to render." },
554
+ claimIds: { type: "array", items: { type: "string" } },
555
+ evidenceIds: { type: "array", items: { type: "string" } },
556
+ sourceNotes: { type: "array", items: { type: "string" } },
557
+ renderNotes: { type: "array", items: { type: "string" } },
558
+ placementNote: { type: "string" },
559
+ },
560
+ required: ["name", "slot", "position", "purpose", "content"],
561
+ additionalProperties: false,
562
+ },
563
+ }
564
+ }
565
+
566
+ function visualIntentProp() {
567
+ return {
568
+ anyOf: [
569
+ { type: "string" },
570
+ {
571
+ type: "object",
572
+ properties: {
573
+ kind: { type: "string" },
574
+ component: { type: "string" },
575
+ rationale: { type: "string" },
576
+ brief: { type: "string" },
577
+ },
578
+ additionalProperties: false,
579
+ },
580
+ ],
581
+ description: "Visual plan or structured visual intent. If component is set, it must exist in components[].name.",
582
+ }
583
+ }
584
+
585
+ function narrativeLinksProp() {
586
+ return {
587
+ type: "object",
588
+ properties: {
589
+ claimIds: { type: "array", items: { type: "string" } },
590
+ evidenceIds: { type: "array", items: { type: "string" } },
591
+ riskIds: { type: "array", items: { type: "string" } },
592
+ objectionIds: { type: "array", items: { type: "string" } },
593
+ gapIds: { type: "array", items: { type: "string" } },
594
+ },
595
+ additionalProperties: false,
596
+ }
597
+ }
598
+
517
599
  function writeMessage(message: any, mode: MessageMode = activeResponseMode): void {
518
600
  activeResponseMode = mode
519
601
  const body = JSON.stringify(message)
@@ -11,7 +11,7 @@ Use this skill when the user asks about Revela designs or when generating deck H
11
11
 
12
12
  1. Call `revela_design_list` to inspect installed designs.
13
13
  2. Call `revela_design_read` with `section: "rules"` before writing or patching `decks/*.html`; this records the Codex hook context required for deck writes.
14
- 3. Call `revela_design_inventory` before authoring or repairing `deck-plan/` so planned layout/component names come from the active design.
14
+ 3. Call `revela_design_inventory` before authoring or repairing `deck-plan/` so planned layout/component names come from the active design. Use `revela_upsert_deck_plan_slide` for slide-plan writes; do not hand-write slide plan Markdown.
15
15
  4. Read required details with `revela_design_read_layout` and `revela_design_read_component` before writing slide HTML that uses those layouts/components.
16
16
  5. When the user asks to switch designs for future work, call `revela_design_activate` with the requested design name, then read the active design again.
17
17
  6. For one-off deck generation with a requested design, read that design by name, call `revela_design_inventory` with that name, and pass `designName` to `revela_create_deck_foundation` without changing active design unless the user asked to switch.
@@ -23,6 +23,12 @@ Deck HTML must keep exactly one direct `.slide-canvas` child inside every `<sect
23
23
 
24
24
  Design changes are visual/artifact-level unless they change claim meaning, evidence boundaries, decision, or recommendation.
25
25
 
26
+ ## Inventory-First Planning
27
+
28
+ Deck planning uses design vocabulary before HTML. Inspect the inventory, choose a valid layout for each slide, and choose valid component names for each planned element. Every component plan must include a semantic `slot` such as `left`, `right`, `top`, `main`, `bottom`, `footer`, or `fullbleed`, plus a non-empty kebab-case `position` such as `left-top`, `left-middle`, `right-bottom`, `center`, or `overlay-top-right`.
29
+
30
+ Use `placementNote` for natural-language placement detail when slot and position are not enough. Slot and position are planning anchors; before HTML generation, fetch the actual layout/component definitions and implement the final structure with the design's CSS and markup.
31
+
26
32
  ## Creating Or Editing Designs
27
33
 
28
34
  When the user asks to create a new design, use `starter` as the default base design unless they specify another base. Interview the user before saving anything: collect visual references such as images, webpages, brands, decks, or text descriptions, plus must-have and must-avoid constraints. Summarize the design brief and visual schema, then wait for the user to confirm before creating files.
@@ -20,11 +20,11 @@ Use this skill when the user asks to make, generate, or update a Revela deck.
20
20
  2. Report narrative and Markdown diagnostics, but treat only malformed/unsafe files and technical artifact validity as hard blockers.
21
21
  3. Call `revela_design_list`, `revela_design_read` using `section: "rules"`, and `revela_design_inventory` before authoring or repairing `deck-plan/`; deck-plan layout/component names must come from the selected design inventory.
22
22
  4. Call `revela_read_deck_plan` as the required deck-plan preflight before any HTML generation.
23
- 5. If `deck-plan/` is missing or incomplete, author or repair `deck-plan/index.md` and `deck-plan/slides/*.md` before calling `revela_create_deck_foundation`; use only inventory-listed layouts/components in slide frontmatter and visual intent.
23
+ 5. If `deck-plan/` is missing or incomplete, call `revela_upsert_deck_plan_slide` for each planned or changed slide before calling `revela_create_deck_foundation`; do not hand-write `deck-plan/slides/*.md`. Use only inventory-listed layouts/components, and provide slot, position, purpose, and exact content for every planned component.
24
24
  6. Report deck-plan diagnostics before artifact generation, including stale narrative hashes, missing slide projections, missing evidence trace, caveats, malformed plan files, or layout/component names outside the active design inventory.
25
25
  7. Do not start HTML generation from narrative alone unless the user explicitly asks for a throwaway diagnostic smoke deck.
26
26
  8. For new HTML files, call `revela_create_deck_foundation`.
27
- 9. Before patching slide HTML, read the specific layouts/components used by the deck plan with `revela_design_read_layout` and `revela_design_read_component`; fetch `section: "chart-rules"` and the `echart-panel` component before creating or changing ECharts. If the user asks to switch designs persistently, call `revela_design_activate`; if they ask for a one-off design, read that design by name, call `revela_design_inventory` with that name, and pass `designName` to `revela_create_deck_foundation`.
27
+ 9. Before patching slide HTML, call `revela_read_deck_plan`, collect the layouts and components from the projection and `componentPlan[]`, then read the specific layouts/components with `revela_design_read_layout` and `revela_design_read_component`; fetch `section: "chart-rules"` and the `echart-panel` component before creating or changing ECharts. If the user asks to switch designs persistently, call `revela_design_activate`; if they ask for a one-off design, read that design by name, call `revela_design_inventory` with that name, pass `designName` to every `revela_upsert_deck_plan_slide` call, and pass `designName` to `revela_create_deck_foundation`.
28
28
  10. Patch slides into the foundation between Revela slide markers. Preserve positive 1-based `data-slide-index` values. Every slide must use `<section class="slide" ...>` with exactly one direct `.slide-canvas` child.
29
29
  11. Generate chapter by chapter. Keep the HTML valid after each write.
30
30
  12. After every HTML write, call `revela_run_deck_qa` and repair hard errors before review or export.
@@ -47,4 +47,11 @@ Use this skill when the user asks to make, generate, or update a Revela deck.
47
47
 
48
48
  ## Deck Plan Requirements
49
49
 
50
- Every deck plan should include Cover, Table of Contents, and Closing. Use 3-5 chapter headings, explicit slide ranges, low-fidelity layout sketches, narrative links, visual intent, evidence trace, and caveats.
50
+ Every deck plan should include Cover, Table of Contents, and Closing. Use 3-5 chapter headings, explicit slide ranges, narrative links, visual intent, evidence trace, and caveats.
51
+
52
+ `revela_upsert_deck_plan_slide` is the required slide-planning write path. For every slide, provide:
53
+
54
+ - `slideIndex`, optional stable `id`, `title`, `chapter`, `narrativeRole`, `structural`, and inventory-listed `layout`.
55
+ - `components[]` entries with inventory-listed `name`, semantic `slot`, kebab-case `position`, `purpose`, exact `content`, plus optional `claimIds`, `evidenceIds`, `sourceNotes`, `renderNotes`, and `placementNote`.
56
+ - `visualIntent`; if `visualIntent.component` is set, it must match one of the component plan names.
57
+ - `narrativeLinks` using canonical claim/evidence/risk/objection/gap ids. Non-structural slides should include at least one claim or evidence link.