@cyber-dash-tech/revela 0.17.22 → 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,11 +1,9 @@
1
1
  /**
2
2
  * DesignManager — manage revela visual design templates.
3
3
  *
4
- * Designs are stored in ~/.config/revela/designs/<name>/.
4
+ * User designs are stored in ~/.config/revela/designs/<name>/.
5
+ * Built-in designs are shipped read-only with this package under designs/<name>/.
5
6
  * Each design directory contains DESIGN.md (required) and optionally preview.html.
6
- *
7
- * Built-in designs are shipped with the npm package under designs/ and seeded
8
- * to the config directory on first run.
9
7
  */
10
8
 
11
9
  import {
@@ -152,21 +150,46 @@ export function parseDesignFile(filePath: string): DesignInfo | null {
152
150
  // Public API
153
151
  // ---------------------------------------------------------------------------
154
152
 
155
- /** List installed designs, sorted by name. Internal designs are hidden by default. */
153
+ function designDirHasPackage(dir: string): boolean {
154
+ return existsSync(dir) && statSync(dir).isDirectory() && existsSync(join(dir, "DESIGN.md"))
155
+ }
156
+
157
+ function resolveDesignDir(nameInput?: string): string | null {
158
+ const name = normalizeDesignName(nameInput || activeDesign())
159
+ const userDir = join(DESIGNS_DIR, name)
160
+ if (designDirHasPackage(userDir)) return userDir
161
+
162
+ const bundledDir = join(SEED_DIR, name)
163
+ if (designDirHasPackage(bundledDir)) return bundledDir
164
+
165
+ return null
166
+ }
167
+
168
+ function readDesignsFromDir(root: string): Map<string, DesignInfo> {
169
+ const designs = new Map<string, DesignInfo>()
170
+ if (!existsSync(root)) return designs
171
+
172
+ for (const entry of readdirSync(root).sort()) {
173
+ const dir = join(root, entry)
174
+ if (!designDirHasPackage(dir)) continue
175
+ const info = parseDesignFile(join(dir, "DESIGN.md"))
176
+ if (info) designs.set(entry, info)
177
+ }
178
+ return designs
179
+ }
180
+
181
+ /** List available designs, sorted by name. User designs override bundled designs with the same name. Internal designs are hidden by default. */
156
182
  export function listDesigns(options: ListDesignsOptions = {}): DesignInfo[] {
157
- if (!existsSync(DESIGNS_DIR)) return []
158
- const results: DesignInfo[] = []
159
183
  const includeInternal = options.includeInternal ?? false
184
+ const available = readDesignsFromDir(SEED_DIR)
160
185
 
161
- for (const entry of readdirSync(DESIGNS_DIR).sort()) {
162
- const dir = join(DESIGNS_DIR, entry)
163
- if (!statSync(dir).isDirectory()) continue
164
- const mdPath = join(dir, "DESIGN.md")
165
- if (!existsSync(mdPath)) continue
166
- const info = parseDesignFile(mdPath)
167
- if (info && (includeInternal || !info.internal)) results.push(info)
186
+ for (const [entry, info] of readDesignsFromDir(DESIGNS_DIR)) {
187
+ available.set(entry, info)
168
188
  }
169
- return results
189
+
190
+ return [...available.values()]
191
+ .filter((info) => includeInternal || !info.internal)
192
+ .sort((a, b) => a.name.localeCompare(b.name))
170
193
  }
171
194
 
172
195
  /** Get the name of the currently active design. */
@@ -187,11 +210,12 @@ export function activateDesign(name: string): void {
187
210
 
188
211
  /** Get the skill text body from a design's DESIGN.md. */
189
212
  export function getDesignSkillMd(name?: string): string {
190
- const designName = name || activeDesign()
191
- const mdPath = join(DESIGNS_DIR, designName, "DESIGN.md")
192
- if (!existsSync(mdPath)) {
213
+ const designName = normalizeDesignName(name || activeDesign())
214
+ const designDir = resolveDesignDir(designName)
215
+ if (!designDir) {
193
216
  throw new Error(`Design '${designName}' is not installed`)
194
217
  }
218
+ const mdPath = join(designDir, "DESIGN.md")
195
219
  const info = parseDesignFile(mdPath)
196
220
  if (!info) {
197
221
  throw new Error(`Failed to parse DESIGN.md for '${designName}'`)
@@ -202,9 +226,8 @@ export function getDesignSkillMd(name?: string): string {
202
226
  /** Resolve a design's preview.html path. Throws if the design is not installed. */
203
227
  export function resolveDesignPreview(name?: string): DesignPreviewInfo {
204
228
  const designName = normalizeDesignName(name || activeDesign())
205
- const designDir = join(DESIGNS_DIR, designName)
206
- const mdPath = join(designDir, "DESIGN.md")
207
- if (!existsSync(designDir) || !existsSync(mdPath)) {
229
+ const designDir = resolveDesignDir(designName)
230
+ if (!designDir) {
208
231
  throw new Error(`Design '${designName}' is not installed`)
209
232
  }
210
233
 
@@ -388,12 +411,15 @@ function hasFixedSizeCssRule(html: string, className: "slide-canvas"): boolean {
388
411
  /** Validate a local design package for the minimum Revela design contract. */
389
412
  export function validateDesignPackage(nameInput: string): ValidateDesignPackageResult {
390
413
  let name = nameInput
414
+ let hasValidName = true
391
415
  try {
392
416
  name = normalizeDesignName(nameInput)
393
417
  } catch {
418
+ hasValidName = false
394
419
  // validateDesignPackageAt records the invalid-name error.
395
420
  }
396
- return validateDesignPackageAt(nameInput, join(DESIGNS_DIR, name))
421
+ const dir = hasValidName ? resolveDesignDir(name) || join(DESIGNS_DIR, name) : join(DESIGNS_DIR, name)
422
+ return validateDesignPackageAt(nameInput, dir)
397
423
  }
398
424
 
399
425
  function validateDesignPackageAt(nameInput: string, dir: string): ValidateDesignPackageResult {
@@ -493,6 +519,25 @@ export interface DesignSections {
493
519
  hasMarkers: boolean
494
520
  }
495
521
 
522
+ export interface DesignInventoryLayout {
523
+ name: string
524
+ qa: boolean
525
+ description: string
526
+ }
527
+
528
+ export interface DesignInventoryComponent {
529
+ name: string
530
+ description: string
531
+ }
532
+
533
+ export interface DesignInventory {
534
+ name: string
535
+ sections: string[]
536
+ layouts: DesignInventoryLayout[]
537
+ components: DesignInventoryComponent[]
538
+ hasMarkers: boolean
539
+ }
540
+
496
541
  /**
497
542
  * Parse a DESIGN.md body (no frontmatter) into sections, layouts, and components
498
543
  * using the three-layer HTML comment marker convention:
@@ -566,7 +611,7 @@ export function generateComponentIndex(components: Record<string, string>): stri
566
611
  "|---|---|",
567
612
  ...rows,
568
613
  "",
569
- "_Use `revela-designs` tool with `action: \"read\"` and `component: \"<name>\"` to get full CSS/HTML for any component._",
614
+ "_Use `revela_design_read_component` with `component: \"<name>\"` to get full CSS/HTML for any component._",
570
615
  ].join("\n")
571
616
  }
572
617
 
@@ -598,10 +643,47 @@ export function generateLayoutIndex(layouts: Record<string, LayoutInfo>): string
598
643
  "|---|---|---|",
599
644
  ...rows,
600
645
  "",
601
- "_Use `revela-designs` tool with `action: \"read\"` and `layout: \"<name>\"` to get full HTML/CSS for any layout._",
646
+ "_Use `revela_design_read_layout` with `layout: \"<name>\"` to get full HTML/CSS for any layout._",
602
647
  ].join("\n")
603
648
  }
604
649
 
650
+ export function getDesignInventory(designName?: string): DesignInventory {
651
+ const name = normalizeDesignName(designName || activeDesign())
652
+ const designDir = resolveDesignDir(name)
653
+ if (!designDir) {
654
+ throw new Error(`Design '${name}' is not installed`)
655
+ }
656
+ const mdPath = join(designDir, "DESIGN.md")
657
+ const text = readFileSync(mdPath, "utf-8")
658
+ const { body } = parseFrontmatter(text)
659
+ const { sections, layouts, components, hasMarkers } = parseDesignSections(body)
660
+
661
+ return {
662
+ name,
663
+ sections: Object.keys(sections),
664
+ layouts: Object.entries(layouts).map(([layoutName, layout]) => ({
665
+ name: layoutName,
666
+ qa: layout.qa,
667
+ description: designBlockDescription(layout.content),
668
+ })),
669
+ components: Object.entries(components).map(([componentName, content]) => ({
670
+ name: componentName,
671
+ description: designBlockDescription(content),
672
+ })),
673
+ hasMarkers,
674
+ }
675
+ }
676
+
677
+ function designBlockDescription(body: string): string {
678
+ const firstLine = body
679
+ .split("\n")
680
+ .map((line) => line.trim())
681
+ .find((line) => line && !line.startsWith("<!--") && !line.startsWith("```"))
682
+ return firstLine
683
+ ? firstLine.replace(/^#+\s*/, "").replace(/\(.*?\)/, "").trim()
684
+ : ""
685
+ }
686
+
605
687
  /**
606
688
  * Get the raw text of one or more named layouts from a DESIGN.md.
607
689
  * @param layoutNames - Comma-separated layout names or an array.
@@ -611,11 +693,12 @@ export function getDesignLayout(
611
693
  layoutNames: string | string[],
612
694
  designName?: string,
613
695
  ): string {
614
- const name = designName || activeDesign()
615
- const mdPath = join(DESIGNS_DIR, name, "DESIGN.md")
616
- if (!existsSync(mdPath)) {
696
+ const name = normalizeDesignName(designName || activeDesign())
697
+ const designDir = resolveDesignDir(name)
698
+ if (!designDir) {
617
699
  throw new Error(`Design '${name}' is not installed`)
618
700
  }
701
+ const mdPath = join(designDir, "DESIGN.md")
619
702
  const text = readFileSync(mdPath, "utf-8")
620
703
  const { body } = parseFrontmatter(text)
621
704
  const { layouts, hasMarkers } = parseDesignSections(body)
@@ -644,11 +727,12 @@ export function getDesignLayout(
644
727
  * Throws if the design is not installed or the section doesn't exist.
645
728
  */
646
729
  export function getDesignSection(sectionName: string, designName?: string): string {
647
- const name = designName || activeDesign()
648
- const mdPath = join(DESIGNS_DIR, name, "DESIGN.md")
649
- if (!existsSync(mdPath)) {
730
+ const name = normalizeDesignName(designName || activeDesign())
731
+ const designDir = resolveDesignDir(name)
732
+ if (!designDir) {
650
733
  throw new Error(`Design '${name}' is not installed`)
651
734
  }
735
+ const mdPath = join(designDir, "DESIGN.md")
652
736
  const text = readFileSync(mdPath, "utf-8")
653
737
  const { body } = parseFrontmatter(text)
654
738
  const { sections, hasMarkers } = parseDesignSections(body)
@@ -671,11 +755,12 @@ export function getDesignComponent(
671
755
  componentNames: string | string[],
672
756
  designName?: string,
673
757
  ): string {
674
- const name = designName || activeDesign()
675
- const mdPath = join(DESIGNS_DIR, name, "DESIGN.md")
676
- if (!existsSync(mdPath)) {
758
+ const name = normalizeDesignName(designName || activeDesign())
759
+ const designDir = resolveDesignDir(name)
760
+ if (!designDir) {
677
761
  throw new Error(`Design '${name}' is not installed`)
678
762
  }
763
+ const mdPath = join(designDir, "DESIGN.md")
679
764
  const text = readFileSync(mdPath, "utf-8")
680
765
  const { body } = parseFrontmatter(text)
681
766
  const { components, hasMarkers } = parseDesignSections(body)
@@ -742,8 +827,7 @@ export async function installDesign(
742
827
  // ---------------------------------------------------------------------------
743
828
 
744
829
  function designExists(name: string): boolean {
745
- const dir = join(DESIGNS_DIR, name)
746
- return existsSync(dir) && existsSync(join(dir, "DESIGN.md"))
830
+ return resolveDesignDir(name) !== null
747
831
  }
748
832
 
749
833
  function installFromPath(srcPath: string, name?: string): string {
@@ -814,13 +898,13 @@ export interface DesignClassVocabulary {
814
898
  * Falls back to UNIVERSAL_CLASSES-only when the design has no markers.
815
899
  */
816
900
  export function extractDesignClasses(designName?: string): DesignClassVocabulary {
817
- const name = designName || activeDesign()
818
- const mdPath = join(DESIGNS_DIR, name, "DESIGN.md")
819
-
820
- if (!existsSync(mdPath)) {
901
+ const name = normalizeDesignName(designName || activeDesign())
902
+ const designDir = resolveDesignDir(name)
903
+ if (!designDir) {
821
904
  return { classes: new Set(UNIVERSAL_CLASSES), prefixExemptions: DEFAULT_PREFIX_EXEMPTIONS }
822
905
  }
823
906
 
907
+ const mdPath = join(designDir, "DESIGN.md")
824
908
  const raw = readFileSync(mdPath, "utf-8")
825
909
  const { body } = parseFrontmatter(raw)
826
910
  const { sections, layouts, components, hasMarkers } = parseDesignSections(body)
@@ -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
  }
@@ -6,6 +6,9 @@ import {
6
6
  activateDesign,
7
7
  createDesignDraftPackage,
8
8
  createDesignPackage,
9
+ getDesignComponent,
10
+ getDesignInventory,
11
+ getDesignLayout,
9
12
  getDesignSection,
10
13
  getDesignSkillMd,
11
14
  installDesignDraftPackage,
@@ -22,7 +25,7 @@ import { autoCompileNarrativeVault } from "../narrative-vault/auto-compile"
22
25
  import { extractNarrativeVaultMarkdownTargetsFromPatch } from "../narrative-vault/hook-targets"
23
26
  import { runNarrativeMarkdownQa, type MarkdownQaOptions } from "../narrative-vault/markdown-qa"
24
27
  import { formatArtifactQaUserNotice, formatMarkdownQaUserNotice } from "../hook-notifications"
25
- import { readDeckPlanArtifact } from "../narrative-state/deck-plan-artifact"
28
+ import { deckPlanDesignDiagnostics, readDeckPlanArtifact, upsertDeckPlanSlideArtifact, type DeckPlanSlideUpsertInput } from "../narrative-state/deck-plan-artifact"
26
29
  import { extractDesignClasses } from "../design/designs"
27
30
  import { recordRenderedArtifact, workspaceRelative } from "../workspace-state/rendered-artifacts"
28
31
  import { checkMaterialIntake, extractMaterial, materialIntakeNoticeForCommand, prepareLocalMaterials, recordMaterialReview } from "../material-intake"
@@ -59,6 +62,24 @@ export interface RuntimeDesignReadInput {
59
62
  section?: string
60
63
  }
61
64
 
65
+ export interface RuntimeDesignInventoryInput {
66
+ name?: string
67
+ }
68
+
69
+ export interface RuntimeDesignLayoutReadInput {
70
+ name?: string
71
+ layout: string | string[]
72
+ }
73
+
74
+ export interface RuntimeDesignComponentReadInput {
75
+ name?: string
76
+ component: string | string[]
77
+ }
78
+
79
+ export interface RuntimeDeckPlanSlideUpsertInput extends RuntimeWorkspaceInput, DeckPlanSlideUpsertInput {
80
+ designName?: string
81
+ }
82
+
62
83
  export interface RuntimeDesignCreateInput {
63
84
  name: string
64
85
  base?: string
@@ -132,10 +153,44 @@ export function readDeckPlan(input: RuntimeWorkspaceInput = {}) {
132
153
  const workspaceRoot = root(input.workspaceRoot)
133
154
  const compiled = compileNarrativeVault(workspaceRoot)
134
155
  const knownNodeIds = compiled.graph ? new Set(compiled.graph.nodes.map((node) => node.id)) : undefined
135
- return readDeckPlanArtifact(workspaceRoot, {
156
+ const read = readDeckPlanArtifact(workspaceRoot, {
136
157
  narrativeHash: compiled.narrative ? computeNarrativeHash(compiled.narrative) : undefined,
137
158
  knownNodeIds,
138
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
+ }
139
194
  }
140
195
 
141
196
  export function createDeckFoundation(input: RuntimeDeckFoundationInput) {
@@ -212,7 +267,6 @@ export async function reviewDeckOpen(input: ReviewDeckOpenInput) {
212
267
  }
213
268
 
214
269
  export function designList() {
215
- seedBuiltinDesigns()
216
270
  return {
217
271
  ok: true,
218
272
  activeDesign: activeDesign(),
@@ -226,7 +280,6 @@ export function designList() {
226
280
  }
227
281
 
228
282
  export function designRead(input: RuntimeDesignReadInput = {}) {
229
- seedBuiltinDesigns()
230
283
  const name = input.name || activeDesign()
231
284
  if (input.section) {
232
285
  const markdown = getDesignSection(input.section, name)
@@ -246,6 +299,33 @@ export function designRead(input: RuntimeDesignReadInput = {}) {
246
299
  }
247
300
  }
248
301
 
302
+ export function designInventory(input: RuntimeDesignInventoryInput = {}) {
303
+ return {
304
+ ok: true,
305
+ ...getDesignInventory(input.name || activeDesign()),
306
+ }
307
+ }
308
+
309
+ export function designReadLayout(input: RuntimeDesignLayoutReadInput) {
310
+ const name = input.name || activeDesign()
311
+ return {
312
+ ok: true,
313
+ name,
314
+ layout: input.layout,
315
+ markdown: getDesignLayout(input.layout, name),
316
+ }
317
+ }
318
+
319
+ export function designReadComponent(input: RuntimeDesignComponentReadInput) {
320
+ const name = input.name || activeDesign()
321
+ return {
322
+ ok: true,
323
+ name,
324
+ component: input.component,
325
+ markdown: getDesignComponent(input.component, name),
326
+ }
327
+ }
328
+
249
329
  export function designCreate(input: RuntimeDesignCreateInput) {
250
330
  seedBuiltinDesigns()
251
331
  return createDesignPackage({
@@ -258,7 +338,6 @@ export function designCreate(input: RuntimeDesignCreateInput) {
258
338
  }
259
339
 
260
340
  export function designValidate(input: RuntimeNameInput) {
261
- seedBuiltinDesigns()
262
341
  return validateDesignPackage(requiredName(input, "design"))
263
342
  }
264
343
 
@@ -296,7 +375,6 @@ export interface DesignRulesReadinessResult {
296
375
  const DESIGN_RULES_MARKER_TTL_MS = 8 * 60 * 60 * 1000
297
376
 
298
377
  export function checkDesignRulesReadiness(input: RuntimeWorkspaceInput = {}): DesignRulesReadinessResult {
299
- seedBuiltinDesigns()
300
378
  const workspaceRoot = root(input.workspaceRoot)
301
379
  const design = activeDesign()
302
380
  const rules = getDesignSection("rules", design)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.17.22",
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.22", "mcp"]
5
+ "args": ["-y", "@cyber-dash-tech/revela@0.17.24", "mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -13,12 +13,16 @@ 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>
19
20
  exportPptx(input: any): Promise<any>
20
21
  designList(): any
21
22
  designRead(input?: any): any
23
+ designInventory(input?: any): any
24
+ designReadLayout(input: any): any
25
+ designReadComponent(input: any): any
22
26
  designActivate(input: any): any
23
27
  designCreate(input: any): any
24
28
  designValidate(input: any): any
@@ -74,6 +78,25 @@ const tools = [
74
78
  description: "Read the file-native deck-plan/ projection and diagnostics.",
75
79
  inputSchema: objectSchema({ workspaceRoot: stringProp("Optional workspace root.") }),
76
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
+ },
77
100
  {
78
101
  name: "revela_create_deck_foundation",
79
102
  description: "Create or repair a file-native Revela HTML deck foundation shell.",
@@ -125,6 +148,29 @@ const tools = [
125
148
  section: stringProp("Optional design section, such as rules, foundation, or chart-rules."),
126
149
  }),
127
150
  },
151
+ {
152
+ name: "revela_design_inventory",
153
+ description: "List the active or requested Revela design sections, layouts, and components.",
154
+ inputSchema: objectSchema({
155
+ name: stringProp("Optional design name."),
156
+ }),
157
+ },
158
+ {
159
+ name: "revela_design_read_layout",
160
+ description: "Read one or more layout blocks from the active or requested Revela design.",
161
+ inputSchema: objectSchema({
162
+ name: stringProp("Optional design name."),
163
+ layout: stringOrArrayProp("Layout name, comma-separated names, or an array of layout names."),
164
+ }, ["layout"]),
165
+ },
166
+ {
167
+ name: "revela_design_read_component",
168
+ description: "Read one or more component blocks from the active or requested Revela design.",
169
+ inputSchema: objectSchema({
170
+ name: stringProp("Optional design name."),
171
+ component: stringOrArrayProp("Component name, comma-separated names, or an array of component names."),
172
+ }, ["component"]),
173
+ },
128
174
  {
129
175
  name: "revela_design_activate",
130
176
  description: "Activate a Revela design for future deck planning and artifact generation.",
@@ -388,12 +434,16 @@ async function callTool(name: string, args: any): Promise<any> {
388
434
  if (name === "revela_compile_narrative") return r.compileNarrative(args)
389
435
  if (name === "revela_markdown_qa") return r.markdownQa(args)
390
436
  if (name === "revela_read_deck_plan") return r.readDeckPlan(args)
437
+ if (name === "revela_upsert_deck_plan_slide") return r.upsertDeckPlanSlide(args)
391
438
  if (name === "revela_create_deck_foundation") return r.createDeckFoundation(args)
392
439
  if (name === "revela_run_deck_qa") return r.runDeckQa(args)
393
440
  if (name === "revela_export_pdf") return r.exportPdf(args)
394
441
  if (name === "revela_export_pptx") return r.exportPptx(args)
395
442
  if (name === "revela_design_list") return r.designList()
396
443
  if (name === "revela_design_read") return r.designRead(args)
444
+ if (name === "revela_design_inventory") return r.designInventory(args)
445
+ if (name === "revela_design_read_layout") return r.designReadLayout(args)
446
+ if (name === "revela_design_read_component") return r.designReadComponent(args)
397
447
  if (name === "revela_design_activate") return r.designActivate(args)
398
448
  if (name === "revela_design_create") return r.designCreate(args)
399
449
  if (name === "revela_design_validate") return r.designValidate(args)
@@ -450,6 +500,10 @@ function numberProp(description: string) {
450
500
  return { type: "number", description }
451
501
  }
452
502
 
503
+ function requiredNumberProp(description: string) {
504
+ return { type: "number", description }
505
+ }
506
+
453
507
  function enumProp(values: string[], description: string) {
454
508
  return { type: "string", enum: values, description }
455
509
  }
@@ -458,6 +512,16 @@ function arrayProp(description: string) {
458
512
  return { type: "array", items: { type: "string" }, description }
459
513
  }
460
514
 
515
+ function stringOrArrayProp(description: string) {
516
+ return {
517
+ anyOf: [
518
+ { type: "string" },
519
+ { type: "array", items: { type: "string" } },
520
+ ],
521
+ description,
522
+ }
523
+ }
524
+
461
525
  function arrayObjectProp(description: string) {
462
526
  return {
463
527
  type: "array",
@@ -475,6 +539,63 @@ function arrayObjectProp(description: string) {
475
539
  }
476
540
  }
477
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
+
478
599
  function writeMessage(message: any, mode: MessageMode = activeResponseMode): void {
479
600
  activeResponseMode = mode
480
601
  const body = JSON.stringify(message)
@@ -11,16 +11,24 @@ 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. 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.
15
- 4. For one-off deck generation with a requested design, read that design by name and pass `designName` to `revela_create_deck_foundation` without changing active design unless the user asked to switch.
16
- 5. Use the current simplified built-in design grammar: `box`, `text-panel`, `media`, `echart-panel`, `data-table`, `steps`, `roadmap-horizontal`, `roadmap-vertical`, `hero`, `stat-card`, `quote`, `toc`, `page-number`, and `brand-watermark`.
17
- 6. Fetch chart/design guidance before creating ECharts or complex layouts.
18
- 7. Do not invent unsupported component names.
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
+ 4. Read required details with `revela_design_read_layout` and `revela_design_read_component` before writing slide HTML that uses those layouts/components.
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
+ 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.
18
+ 7. Use the current simplified built-in design grammar: `box`, `text-panel`, `media`, `echart-panel`, `data-table`, `steps`, `roadmap-horizontal`, `roadmap-vertical`, `hero`, `stat-card`, `quote`, `toc`, `page-number`, and `brand-watermark`.
19
+ 8. Fetch chart/design guidance before creating ECharts or complex layouts.
20
+ 9. Do not invent unsupported component names.
19
21
 
20
22
  Deck HTML must keep exactly one direct `.slide-canvas` child inside every `<section class="slide" ...>`; place `.page` or layout containers inside `.slide-canvas`, not directly under `.slide`.
21
23
 
22
24
  Design changes are visual/artifact-level unless they change claim meaning, evidence boundaries, decision, or recommendation.
23
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
+
24
32
  ## Creating Or Editing Designs
25
33
 
26
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.
@@ -18,15 +18,16 @@ Use this skill when the user asks to make, generate, or update a Revela deck.
18
18
 
19
19
  1. Call `revela_compile_narrative` and `revela_markdown_qa`.
20
20
  2. Report narrative and Markdown diagnostics, but treat only malformed/unsafe files and technical artifact validity as hard blockers.
21
- 3. Call `revela_read_deck_plan` as the required deck-plan preflight before any HTML generation.
22
- 4. 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`.
23
- 5. Report deck-plan diagnostics before artifact generation, including stale narrative hashes, missing slide projections, missing evidence trace, caveats, or malformed plan files.
24
- 6. Do not start HTML generation from narrative alone unless the user explicitly asks for a throwaway diagnostic smoke deck.
25
- 7. For new HTML files, call `revela_create_deck_foundation`.
26
- 8. Read active design guidance with `revela_design_list` and `revela_design_read` using `section: "rules"` before writing `decks/*.html`; fetch layouts/components/chart rules as needed. 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 and pass `designName` to `revela_create_deck_foundation`.
27
- 9. 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.
28
- 10. Generate chapter by chapter. Keep the HTML valid after each write.
29
- 11. After every HTML write, call `revela_run_deck_qa` and repair hard errors before review or export.
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
+ 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, 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
+ 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
+ 7. Do not start HTML generation from narrative alone unless the user explicitly asks for a throwaway diagnostic smoke deck.
26
+ 8. For new HTML files, call `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
+ 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
+ 11. Generate chapter by chapter. Keep the HTML valid after each write.
30
+ 12. After every HTML write, call `revela_run_deck_qa` and repair hard errors before review or export.
30
31
 
31
32
  ## Generated Visual Assets
32
33
 
@@ -46,4 +47,11 @@ Use this skill when the user asks to make, generate, or update a Revela deck.
46
47
 
47
48
  ## Deck Plan Requirements
48
49
 
49
- 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.