@cyber-dash-tech/revela 0.16.4 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +7 -5
  2. package/README.zh-CN.md +7 -5
  3. package/lib/commands/brief.ts +9 -0
  4. package/lib/commands/help.ts +5 -2
  5. package/lib/commands/init.ts +42 -27
  6. package/lib/commands/narrative.ts +26 -2
  7. package/lib/commands/research.ts +36 -20
  8. package/lib/commands/review.ts +21 -18
  9. package/lib/ctx.ts +1 -1
  10. package/lib/decks-state.ts +38 -4
  11. package/lib/edit/prompt.ts +1 -1
  12. package/lib/hook-notifications.ts +53 -0
  13. package/lib/narrative-state/render-plan.ts +114 -27
  14. package/lib/narrative-state/research-binding-eval.ts +260 -0
  15. package/lib/narrative-state/research-gaps.ts +2 -88
  16. package/lib/narrative-vault/authoring-contract.ts +127 -0
  17. package/lib/narrative-vault/authoring-guard.ts +122 -0
  18. package/lib/narrative-vault/auto-compile.ts +134 -0
  19. package/lib/narrative-vault/bootstrap.ts +63 -0
  20. package/lib/narrative-vault/cache.ts +14 -0
  21. package/lib/narrative-vault/compile-mirror.ts +45 -0
  22. package/lib/narrative-vault/compile.ts +350 -0
  23. package/lib/narrative-vault/constants.ts +6 -0
  24. package/lib/narrative-vault/diagnostic-report.ts +117 -0
  25. package/lib/narrative-vault/export.ts +71 -0
  26. package/lib/narrative-vault/frontmatter.ts +41 -0
  27. package/lib/narrative-vault/hook-targets.ts +40 -0
  28. package/lib/narrative-vault/index.ts +18 -0
  29. package/lib/narrative-vault/inventory.ts +392 -0
  30. package/lib/narrative-vault/markdown-qa.ts +237 -0
  31. package/lib/narrative-vault/markdown.ts +34 -0
  32. package/lib/narrative-vault/migration.ts +52 -0
  33. package/lib/narrative-vault/mutate.ts +361 -0
  34. package/lib/narrative-vault/paths.ts +19 -0
  35. package/lib/narrative-vault/read.ts +52 -0
  36. package/lib/narrative-vault/relations.ts +32 -0
  37. package/lib/narrative-vault/source-loader.ts +19 -0
  38. package/lib/narrative-vault/timestamp.ts +32 -0
  39. package/lib/narrative-vault/types.ts +44 -0
  40. package/lib/source-materials.ts +98 -0
  41. package/lib/tool-result.ts +34 -0
  42. package/package.json +2 -2
  43. package/plugin.ts +60 -22
  44. package/skill/NARRATIVE_SKILL.md +25 -10
  45. package/tools/decks.ts +363 -67
  46. package/tools/research-save.ts +3 -0
  47. package/tools/workspace-scan.ts +1 -0
@@ -20,7 +20,8 @@ import { WORKSPACE_STATE_FILE, type RenderTarget, type ReviewSnapshot, type Work
20
20
  import { normalizeCanonicalNarrativeState, normalizeNarrativeState } from "./narrative-state/normalize"
21
21
  import { computeNarrativeHash } from "./narrative-state/hash"
22
22
  import { getArtifactClaimRefs } from "./narrative-state/queries"
23
- import type { NarrativeStateV1 } from "./narrative-state/types"
23
+ import type { NarrativeApproval, NarrativeStateV1 } from "./narrative-state/types"
24
+ import { hasNarrativeVault, loadNarrativeFromPreferredSource } from "./narrative-vault"
24
25
 
25
26
  export const DECKS_STATE_FILE = WORKSPACE_STATE_FILE
26
27
 
@@ -34,6 +35,7 @@ export interface DecksState {
34
35
  version: 1
35
36
  activeDeck?: string
36
37
  narrative?: NarrativeStateV1
38
+ narrativeApprovals?: NarrativeApproval[]
37
39
  workspace: {
38
40
  brief?: string
39
41
  sourceMaterials: SourceMaterial[]
@@ -55,6 +57,7 @@ export interface SourceMaterial {
55
57
  type?: string
56
58
  size?: number
57
59
  fingerprint?: string
60
+ lastModified?: string
58
61
  status?: "discovered" | "extracted" | "summarized" | "researched"
59
62
  extraction?: {
60
63
  manifestPath?: string
@@ -483,15 +486,37 @@ export function confirmDeckPlan(state: DecksState, options: ConfirmDeckPlanOptio
483
486
  }
484
487
 
485
488
  export function readDecksState(workspaceRoot: string): DecksState {
486
- return readWorkspaceState(workspaceRoot, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksStateWithNarrative })
489
+ return applyPreferredNarrativeSource(workspaceRoot, readWorkspaceState(workspaceRoot, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksStateWithNarrative }))
487
490
  }
488
491
 
489
492
  export function writeDecksState(workspaceRoot: string, state: DecksState): void {
490
- writeWorkspaceState(workspaceRoot, state, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksStateWithNarrative })
493
+ const vault = hasNarrativeVault(workspaceRoot)
494
+ writeWorkspaceState(workspaceRoot, prepareStateForWrite(workspaceRoot, state), { fileName: DECKS_STATE_FILE, normalize: vault ? normalizeDecksState : normalizeDecksStateWithNarrative })
491
495
  }
492
496
 
493
497
  export function readOrCreateDecksState(workspaceRoot: string): DecksState {
494
- return readOrCreateWorkspaceState(workspaceRoot, createEmptyDecksState, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksStateWithNarrative })
498
+ return applyPreferredNarrativeSource(workspaceRoot, readOrCreateWorkspaceState(workspaceRoot, createEmptyDecksState, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksStateWithNarrative }))
499
+ }
500
+
501
+ function applyPreferredNarrativeSource(workspaceRoot: string, state: DecksState): DecksState {
502
+ const normalized = normalizeDecksStateWithNarrative(state)
503
+ const loaded = loadNarrativeFromPreferredSource(workspaceRoot, normalized.narrative, narrativeApprovalsForHydration(normalized))
504
+ if (loaded.source !== "vault" || !loaded.narrative) return normalized
505
+ return normalizeDecksStateWithNarrative({ ...normalized, narrative: loaded.narrative, narrativeApprovals: loaded.narrative.approvals })
506
+ }
507
+
508
+ function prepareStateForWrite(workspaceRoot: string, state: DecksState): DecksState {
509
+ const normalized = normalizeDecksStateWithNarrative(state)
510
+ if (!hasNarrativeVault(workspaceRoot)) return normalized
511
+ const loaded = loadNarrativeFromPreferredSource(workspaceRoot, normalized.narrative, narrativeApprovalsForHydration(normalized))
512
+ const narrativeApprovals = loaded.narrative?.approvals ?? narrativeApprovalsForHydration(normalized)
513
+ const prepared = normalizeDecksStateWithNarrative({ ...normalized, narrative: loaded.narrative ?? normalized.narrative, narrativeApprovals })
514
+ const { narrative: _narrative, ...withoutNarrative } = prepared
515
+ return withoutNarrative as DecksState
516
+ }
517
+
518
+ function narrativeApprovalsForHydration(state: DecksState): NarrativeApproval[] {
519
+ return state.narrativeApprovals ?? state.narrative?.approvals ?? []
495
520
  }
496
521
 
497
522
  export function upsertDeck(state: DecksState, input: Partial<DeckSpec> & { slug: string }): DecksState {
@@ -859,6 +884,7 @@ function normalizeDecksState(input: DecksState): DecksState {
859
884
  version: 1,
860
885
  activeDeck: input.activeDeck ? normalizeSlug(input.activeDeck) : undefined,
861
886
  narrative: normalizeCanonicalNarrativeState(input.narrative, input.activeDeck || "workspace"),
887
+ narrativeApprovals: normalizeNarrativeApprovals([...(input.narrativeApprovals ?? []), ...(input.narrative?.approvals ?? [])]),
862
888
  workspace: {
863
889
  brief: input.workspace?.brief,
864
890
  sourceMaterials: input.workspace?.sourceMaterials ?? [],
@@ -890,9 +916,17 @@ function normalizeDecksState(input: DecksState): DecksState {
890
916
  function normalizeDecksStateWithNarrative(input: DecksState): DecksState {
891
917
  const state = normalizeDecksState(input)
892
918
  if (!state.narrative && currentDeckKey(state)) state.narrative = normalizeNarrativeState(state)
919
+ if (state.narrative && state.narrativeApprovals && state.narrativeApprovals.length > 0) {
920
+ state.narrative = { ...state.narrative, approvals: normalizeNarrativeApprovals([...state.narrative.approvals, ...state.narrativeApprovals]) ?? [] }
921
+ }
893
922
  return state
894
923
  }
895
924
 
925
+ function normalizeNarrativeApprovals(approvals: NarrativeApproval[]): NarrativeApproval[] | undefined {
926
+ const normalized = [...new Map(approvals.filter((approval) => approval?.id).map((approval) => [approval.id, approval])).values()]
927
+ return normalized.length > 0 ? normalized : undefined
928
+ }
929
+
896
930
  function normalizeDeckPlanReview(input: DeckPlanReview | undefined): DeckPlanReview | undefined {
897
931
  if (!input || !input.narrativeHash || !input.planHash) return undefined
898
932
  return {
@@ -75,7 +75,7 @@ Instructions:
75
75
  - Make the smallest targeted change that satisfies the user's comment.
76
76
  - If there are multiple comments, apply them as one coherent edit pass and avoid changes from one comment overwriting another.
77
77
  - Each comment may reference one or more selected elements. Treat the elements in a single comment as a group.
78
- - Preserve the narrative boundary: if the requested edit changes audience framing, belief shift, decision/action, thesis, recommendation, claim wording, evidence scope, caveat, risk, objection, or decision ask, do not patch the HTML directly. Explain that the canonical narrative must be updated first through ${"`revela-decks`"} action ${"`upsertNarrative`"}, then reviewed/approved or explicitly overridden before updating the deck projection.
78
+ - Preserve the narrative boundary: if the requested edit changes audience framing, belief shift, decision/action, thesis, recommendation, claim wording, evidence scope, caveat, risk, objection, or decision ask, do not patch the HTML directly. Explain that the canonical narrative must be updated first through targeted ${"`revela-decks`"} vault actions (${"`initNarrativeVault`"} if needed, then ${"`updateVaultCoreNarrative`"}, ${"`upsertVaultClaim`"}, ${"`upsertVaultEvidence`"}, ${"`upsertVaultObjection`"}, or ${"`upsertVaultRisk`"}), with manual Markdown edits plus ${"`compileNarrativeVault`"} only for unsupported node changes. Then the narrative must be reviewed/approved or explicitly overridden before updating the deck projection.
79
79
  - Pure artifact polish such as layout, spacing, typography, alignment, color, image crop, animation, export fidelity, runtime JavaScript fixes, or deck HTML contract fixes may remain an artifact-level edit.
80
80
  - If the request mixes content meaning and visual polish, treat it as narrative-impacting unless the user clarifies otherwise.
81
81
  - Preserve the existing deck structure, active design language, typography, spacing system, animations, and slide count unless the comment explicitly asks otherwise.
@@ -0,0 +1,53 @@
1
+ import type { ArtifactQAReport } from "./qa/artifact"
2
+ import type { AutoCompileNarrativeVaultResult } from "./narrative-vault/auto-compile"
3
+
4
+ export function formatMarkdownQaUserNotice(result: AutoCompileNarrativeVaultResult): string | undefined {
5
+ if (result.ok) return undefined
6
+
7
+ const lines = ["**Markdown QA blocked**"]
8
+ lines.push(`Touched: ${result.touched.length > 0 ? result.touched.map((file) => `\`${file}\``).join(", ") : "unknown"}`)
9
+
10
+ const blockers = result.markdownQa?.blockers ?? []
11
+ if (blockers.length > 0) {
12
+ lines.push("Top repair(s):")
13
+ for (const card of blockers.slice(0, 3)) {
14
+ const location = [card.file, card.nodeId].filter(Boolean).join(" / ")
15
+ lines.push(`- \`${card.issueCode}\`${location ? ` (${location})` : ""}: ${card.smallestRepair}`)
16
+ }
17
+ if (blockers.length > 3) lines.push(`- ... ${blockers.length - 3} more`)
18
+ } else if (result.error) {
19
+ lines.push(`Hook error: ${result.error}`)
20
+ } else {
21
+ lines.push("Compile diagnostics are blocking the vault. See the tool output for details.")
22
+ }
23
+
24
+ return lines.join("\n")
25
+ }
26
+
27
+ export function formatArtifactQaUserNotice(report: ArtifactQAReport): string | undefined {
28
+ if (report.passed) return undefined
29
+
30
+ const lines = ["**Artifact QA failed**"]
31
+ lines.push(`File: \`${report.file}\``)
32
+ lines.push(`Hard errors: ${report.hardErrorCount}; warnings: ${report.warningCount}`)
33
+ if (report.sections.length > 0) {
34
+ lines.push("Top issue area(s):")
35
+ for (const section of report.sections.slice(0, 3)) lines.push(`- ${firstLine(section)}`)
36
+ if (report.sections.length > 3) lines.push(`- ... ${report.sections.length - 3} more`)
37
+ }
38
+ lines.push("Fix the reported artifact issues before treating the deck as ready.")
39
+ return lines.join("\n")
40
+ }
41
+
42
+ export function formatStateGateUserNotice(kind: "write" | "patch", reason: string): string {
43
+ return [
44
+ "**Revela state gate blocked a direct DECKS.json edit**",
45
+ `Operation: ${kind}`,
46
+ `Reason: ${reason}`,
47
+ "Use the `revela-decks` tool for controlled workspace state changes.",
48
+ ].join("\n")
49
+ }
50
+
51
+ function firstLine(text: string): string {
52
+ return text.split(/\r?\n/).map((line) => line.trim()).find(Boolean)?.replace(/^#+\s*/, "") ?? "See report details."
53
+ }
@@ -17,9 +17,18 @@ export interface CompileDeckPlanResult {
17
17
  narrativeHash: string
18
18
  slideCount: number
19
19
  slides: SlideSpec[]
20
+ chapters?: DeckPlanChapter[]
20
21
  qualityChecks?: DeckPlanQualityCheck[]
21
22
  }
22
23
 
24
+ export interface DeckPlanChapter {
25
+ title: string
26
+ role: "context" | "tension" | "evidence" | "recommendation" | "risk" | "ask"
27
+ slideIndexes: number[]
28
+ claimIds: string[]
29
+ evidenceBindingIds: string[]
30
+ }
31
+
23
32
  export interface DeckPlanQualityCheck {
24
33
  id: string
25
34
  status: "pass" | "warning" | "blocker"
@@ -47,8 +56,9 @@ export function compileDeckPlanFromNarrative(state: DecksState, options: Compile
47
56
  const deckKey = state.activeDeck ?? Object.keys(state.decks)[0]
48
57
  const deck = deckKey ? state.decks[deckKey] : undefined
49
58
  const slug = deck?.slug ?? state.activeDeck ?? "deck"
50
- const slides = buildSlides(narrative)
51
- const qualityChecks = checkPlanQuality(narrative, slides)
59
+ const plan = buildDeckPlan(narrative)
60
+ const { slides, chapters } = plan
61
+ const qualityChecks = checkPlanQuality(narrative, slides, chapters)
52
62
  const planCoverage = deckPlanCoverage(narrative, slides)
53
63
  const requiredInputs: Partial<RequiredInputs> = {
54
64
  topicClarified: true,
@@ -91,8 +101,9 @@ export function compileDeckPlanFromNarrative(state: DecksState, options: Compile
91
101
  ...(htmlTarget.data ?? {}),
92
102
  narrativeId: narrative.id,
93
103
  narrativeHash,
94
- planQualityChecks: qualityChecks,
95
- requiredClaimIds: planCoverage.requiredClaimIds,
104
+ planQualityChecks: qualityChecks,
105
+ planChapters: chapters,
106
+ requiredClaimIds: planCoverage.requiredClaimIds,
96
107
  coveredClaimIds: planCoverage.coveredClaimIds,
97
108
  missingClaimIds: planCoverage.missingClaimIds,
98
109
  claimSlideRefs: getClaimSlideRefs(next).map((ref) => ({
@@ -115,33 +126,45 @@ export function compileDeckPlanFromNarrative(state: DecksState, options: Compile
115
126
  narrativeHash,
116
127
  slideCount: slides.length,
117
128
  slides,
129
+ chapters,
118
130
  qualityChecks,
119
131
  },
120
132
  }
121
133
  }
122
134
 
123
- function buildSlides(narrative: NarrativeStateV1): SlideSpec[] {
135
+ function buildDeckPlan(narrative: NarrativeStateV1): { slides: SlideSpec[]; chapters: DeckPlanChapter[] } {
124
136
  const slides: SlideSpec[] = []
125
137
  const evidenceByClaim = evidenceBindingsByClaim(narrative.evidenceBindings)
126
138
  const centralClaims = orderedClaims(narrative, (claim) => claim.importance === "central")
127
139
  const supportingClaims = orderedClaims(narrative, (claim) => claim.importance !== "central")
128
- const chapters = deriveChapters(narrative, centralClaims, supportingClaims)
140
+ const chapters = deriveChapters(narrative, centralClaims, supportingClaims).map((chapter) => ({ ...chapter }))
129
141
 
130
142
  slides.push(coverSlide(slides.length + 1, narrative))
143
+ assignSlideToChapter(chapters, "context", slides[slides.length - 1])
131
144
  slides.push(tocSlide(slides.length + 1, chapters))
132
145
 
133
146
  for (const claim of centralClaims) {
134
- slides.push(claimSlide(slides.length + 1, claim, evidenceByClaim.get(claim.id) ?? []))
147
+ const slide = claimSlide(slides.length + 1, claim, evidenceByClaim.get(claim.id) ?? [])
148
+ slides.push(slide)
149
+ assignSlideToChapter(chapters, chapterRoleForClaim(claim), slide)
150
+ }
151
+ if (supportingClaims.length > 0) {
152
+ const slide = supportingLogicSlide(slides.length + 1, supportingClaims, evidenceByClaim)
153
+ slides.push(slide)
154
+ assignSlideToChapter(chapters, "evidence", slide)
135
155
  }
136
- if (supportingClaims.length > 0) slides.push(supportingLogicSlide(slides.length + 1, supportingClaims, evidenceByClaim))
137
156
 
138
157
  if (narrative.risks.length > 0 || narrative.objections.length > 0) {
139
- slides.push(riskObjectionSlide(slides.length + 1, narrative))
158
+ const slide = riskObjectionSlide(slides.length + 1, narrative, evidenceByClaim)
159
+ slides.push(slide)
160
+ assignSlideToChapter(chapters, "risk", slide)
140
161
  }
141
162
 
142
- slides.push(decisionAskSlide(slides.length + 1, narrative))
163
+ const decisionSlide = decisionAskSlide(slides.length + 1, narrative)
164
+ slides.push(decisionSlide)
165
+ assignSlideToChapter(chapters, "ask", decisionSlide)
143
166
 
144
- return slides
167
+ return { slides, chapters }
145
168
  }
146
169
 
147
170
  function coverSlide(index: number, narrative: NarrativeStateV1): SlideSpec {
@@ -166,7 +189,7 @@ function coverSlide(index: number, narrative: NarrativeStateV1): SlideSpec {
166
189
  }
167
190
  }
168
191
 
169
- function tocSlide(index: number, chapters: string[]): SlideSpec {
192
+ function tocSlide(index: number, chapters: DeckPlanChapter[]): SlideSpec {
170
193
  return {
171
194
  index,
172
195
  title: "Storyline",
@@ -177,7 +200,8 @@ function tocSlide(index: number, chapters: string[]): SlideSpec {
177
200
  components: ["toc", "text-panel"],
178
201
  content: {
179
202
  headline: "How the decision story is organized",
180
- bullets: chapters,
203
+ bullets: chapters.map((chapter) => chapter.title),
204
+ data: { chapters: chapters.map((chapter) => ({ title: chapter.title, role: chapter.role })) },
181
205
  },
182
206
  evidence: [],
183
207
  status: "planned",
@@ -220,19 +244,20 @@ function supportingLogicSlide(index: number, claims: NarrativeClaim[], evidenceB
220
244
  evidenceBindingIds: supportingBindings.map((binding) => binding.id),
221
245
  content: {
222
246
  headline: "Supporting claims and boundaries",
223
- bullets: claims.slice(0, 5).flatMap((claim) => [claim.text, ...claimBoundaryBullets(claim)]).slice(0, 8),
247
+ bullets: claims.slice(0, 5).flatMap((claim) => [claim.text, ...claimBoundaryBullets(claim), evidenceGapBullet(claim, evidenceByClaim.get(claim.id) ?? [])]).filter((item): item is string => Boolean(item)).slice(0, 8),
224
248
  },
225
249
  evidence: supportingBindings.map(evidenceRefFromBinding),
226
250
  status: "planned",
227
251
  }
228
252
  }
229
253
 
230
- function riskObjectionSlide(index: number, narrative: NarrativeStateV1): SlideSpec {
254
+ function riskObjectionSlide(index: number, narrative: NarrativeStateV1, evidenceByClaim: Map<string, NarrativeEvidenceBinding[]>): SlideSpec {
231
255
  const challengedClaimRefs = [
232
256
  ...narrative.risks.map((risk) => risk.claimId ? { claimId: risk.claimId, role: "risk" as const } : undefined).filter((ref): ref is { claimId: string; role: "risk" } => Boolean(ref)),
233
257
  ...narrative.objections.map((objection) => objection.claimId ? { claimId: objection.claimId, role: "objection" as const } : undefined).filter((ref): ref is { claimId: string; role: "objection" } => Boolean(ref)),
234
258
  ]
235
259
  const challengedClaimIds = [...new Set(challengedClaimRefs.map((ref) => ref.claimId))]
260
+ const challengedBindings = challengedClaimIds.flatMap((claimId) => evidenceByClaim.get(claimId) ?? [])
236
261
  return {
237
262
  index,
238
263
  title: "Risks And Objections",
@@ -243,6 +268,7 @@ function riskObjectionSlide(index: number, narrative: NarrativeStateV1): SlideSp
243
268
  components: ["box", "text-panel"],
244
269
  claimIds: challengedClaimIds,
245
270
  claimRefs: dedupeClaimRefs(challengedClaimRefs),
271
+ evidenceBindingIds: challengedBindings.map((binding) => binding.id),
246
272
  content: {
247
273
  headline: "What could break the recommendation",
248
274
  bullets: [
@@ -250,7 +276,7 @@ function riskObjectionSlide(index: number, narrative: NarrativeStateV1): SlideSp
250
276
  ...narrative.objections.slice(0, 3).map((objection) => objection.response ? `${objection.text} Response: ${objection.response}` : objection.text),
251
277
  ],
252
278
  },
253
- evidence: [],
279
+ evidence: challengedBindings.map(evidenceRefFromBinding),
254
280
  status: "planned",
255
281
  }
256
282
  }
@@ -302,22 +328,51 @@ function orderedClaims(narrative: NarrativeStateV1, predicate: (claim: Narrative
302
328
  .sort((a, b) => (relationScore.get(b.id) ?? 0) - (relationScore.get(a.id) ?? 0) || (sourceOrder.get(a.id) ?? 0) - (sourceOrder.get(b.id) ?? 0))
303
329
  }
304
330
 
305
- function deriveChapters(narrative: NarrativeStateV1, centralClaims: NarrativeClaim[], supportingClaims: NarrativeClaim[]): string[] {
306
- const chapters: string[] = []
307
- addUnique(chapters, narrative.audience.decisionContext ? "Decision context" : "Context and belief shift")
308
- if (hasClaimKind([...centralClaims, ...supportingClaims], ["problem", "opportunity"])) addUnique(chapters, "Tension and opportunity")
309
- if (centralClaims.some((claim) => claim.kind === "evidence") || supportingClaims.some((claim) => claim.kind === "evidence")) addUnique(chapters, "Evidence and proof")
310
- if (centralClaims.some((claim) => claim.kind === "recommendation" || claim.kind === "ask") || narrative.decision.action) addUnique(chapters, "Recommendation and decision")
311
- if (narrative.risks.length > 0 || narrative.objections.length > 0 || centralClaims.some((claim) => claim.unsupportedScope || (claim.caveats ?? []).length > 0)) addUnique(chapters, "Risks and boundaries")
312
- addUnique(chapters, "Decision ask")
313
- if (chapters.length < 3) addUnique(chapters, "Evidence and proof")
314
- return chapters.slice(0, 5)
331
+ function deriveChapters(narrative: NarrativeStateV1, centralClaims: NarrativeClaim[], supportingClaims: NarrativeClaim[]): DeckPlanChapter[] {
332
+ const claims = [...centralClaims, ...supportingClaims]
333
+ const chapters: DeckPlanChapter[] = []
334
+ addChapter(chapters, narrative.audience.decisionContext ? "Decision context" : "Context and belief shift", "context")
335
+ if (hasClaimKind(claims, ["problem", "opportunity"])) addChapter(chapters, "Tension and opportunity", "tension")
336
+ if (claims.some((claim) => claim.kind === "evidence") || narrative.evidenceBindings.length > 0) addChapter(chapters, "Evidence and proof", "evidence")
337
+ if (claims.some((claim) => claim.kind === "recommendation" || claim.kind === "ask") || narrative.decision.action) addChapter(chapters, "Recommendation and decision", "recommendation")
338
+ if (narrative.risks.length > 0 || narrative.objections.length > 0 || centralClaims.some((claim) => claim.unsupportedScope || (claim.caveats ?? []).length > 0)) addChapter(chapters, "Risks and boundaries", "risk")
339
+ addChapter(chapters, "Decision ask", "ask")
340
+ if (chapters.length < 3) addChapter(chapters, "Evidence and proof", "evidence")
341
+ while (chapters.length > 5) {
342
+ const tensionIndex = chapters.findIndex((chapter) => chapter.role === "tension")
343
+ if (tensionIndex >= 0) chapters.splice(tensionIndex, 1)
344
+ else chapters.splice(Math.max(1, chapters.length - 2), 1)
345
+ }
346
+ return chapters
347
+ }
348
+
349
+ function addChapter(chapters: DeckPlanChapter[], title: string, role: DeckPlanChapter["role"]): void {
350
+ if (chapters.some((chapter) => chapter.role === role || chapter.title === title)) return
351
+ chapters.push({ title, role, slideIndexes: [], claimIds: [], evidenceBindingIds: [] })
352
+ }
353
+
354
+ function assignSlideToChapter(chapters: DeckPlanChapter[], role: DeckPlanChapter["role"], slide: SlideSpec): void {
355
+ const chapter = chapters.find((item) => item.role === role) ?? chapters.find((item) => item.role === "evidence") ?? chapters[chapters.length - 1]
356
+ if (!chapter) return
357
+ chapter.slideIndexes.push(slide.index)
358
+ for (const claimId of slide.claimIds ?? []) addUnique(chapter.claimIds, claimId)
359
+ for (const ref of slide.claimRefs ?? []) addUnique(chapter.claimIds, ref.claimId)
360
+ for (const bindingId of slide.evidenceBindingIds ?? []) addUnique(chapter.evidenceBindingIds, bindingId)
315
361
  }
316
362
 
317
363
  function addUnique(items: string[], item: string): void {
318
364
  if (!items.includes(item)) items.push(item)
319
365
  }
320
366
 
367
+ function chapterRoleForClaim(claim: NarrativeClaim): DeckPlanChapter["role"] {
368
+ if (claim.kind === "problem" || claim.kind === "opportunity") return "tension"
369
+ if (claim.kind === "recommendation") return "recommendation"
370
+ if (claim.kind === "ask") return "ask"
371
+ if (claim.kind === "risk" || claim.kind === "assumption") return "risk"
372
+ if (claim.kind === "context") return "context"
373
+ return "evidence"
374
+ }
375
+
321
376
  function hasClaimKind(claims: NarrativeClaim[], kinds: NarrativeClaim["kind"][]): boolean {
322
377
  return claims.some((claim) => kinds.includes(claim.kind))
323
378
  }
@@ -332,9 +387,15 @@ function claimBullets(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[
332
387
  return [
333
388
  ...claimBoundaryBullets(claim),
334
389
  ...bindings.slice(0, 2).map((binding) => binding.supportScope ? `Evidence supports: ${binding.supportScope}` : undefined),
390
+ evidenceGapBullet(claim, bindings),
335
391
  ].filter((item): item is string => Boolean(item))
336
392
  }
337
393
 
394
+ function evidenceGapBullet(claim: NarrativeClaim, bindings: NarrativeEvidenceBinding[]): string | undefined {
395
+ if (!claim.evidenceRequired || bindings.length > 0) return undefined
396
+ return `Evidence gap: ${claim.evidenceStatus === "missing" ? "no binding yet" : "support remains incomplete"}.`
397
+ }
398
+
338
399
  function claimBoundaryBullets(claim: NarrativeClaim): string[] {
339
400
  return [
340
401
  claim.supportedScope ? `Supported scope: ${claim.supportedScope}` : undefined,
@@ -370,13 +431,29 @@ function evidenceRefFromBinding(binding: NarrativeEvidenceBinding): EvidenceRef
370
431
  }
371
432
  }
372
433
 
373
- function checkPlanQuality(narrative: NarrativeStateV1, slides: SlideSpec[]): DeckPlanQualityCheck[] {
434
+ function checkPlanQuality(narrative: NarrativeStateV1, slides: SlideSpec[], chapters: DeckPlanChapter[]): DeckPlanQualityCheck[] {
374
435
  const coverage = deckPlanCoverage(narrative, slides)
375
436
  const centralClaimIds = narrative.claims.filter((claim) => claim.importance === "central").map((claim) => claim.id)
376
437
  const missingCentralClaims = centralClaimIds.filter((claimId) => coverage.missingClaimIds.includes(claimId))
377
438
  const incompatibleComponents = [...new Set(slides.flatMap((slide) => slide.components).filter((component) => component === "card"))]
439
+ const toc = slides.find((slide) => slide.components.includes("toc"))
440
+ const tocBullets = toc?.content.bullets ?? []
441
+ const chapterTitles = chapters.map((chapter) => chapter.title)
442
+ const evidenceRequiredWithoutBindings = narrative.claims.filter((claim) => claim.evidenceRequired && !narrative.evidenceBindings.some((binding) => binding.claimId === claim.id))
443
+ const invisibleEvidenceGaps = evidenceRequiredWithoutBindings.filter((claim) => coverage.missingClaimIds.includes(claim.id))
444
+ const risksOrObjectionsVisible = narrative.risks.length === 0 && narrative.objections.length === 0 || slides.some((slide) => slide.narrativeRole === "risk")
378
445
 
379
446
  return [
447
+ {
448
+ id: "chapter_structure_present",
449
+ status: chapters.length >= 3 && chapters.length <= 5 && chapters.every((chapter) => chapter.slideIndexes.length > 0) ? "pass" : "blocker",
450
+ message: chapters.length >= 3 && chapters.length <= 5 && chapters.every((chapter) => chapter.slideIndexes.length > 0) ? `Deck plan includes ${chapters.length} deterministic chapters with slide ranges.` : "Deck plan must include 3-5 deterministic chapters, each mapped to at least one slide.",
451
+ },
452
+ {
453
+ id: "toc_matches_chapters",
454
+ status: chapterTitles.length > 0 && chapterTitles.every((title) => tocBullets.includes(title)) ? "pass" : "blocker",
455
+ message: chapterTitles.length > 0 && chapterTitles.every((title) => tocBullets.includes(title)) ? "TOC headings match the deterministic chapter plan." : "TOC headings do not match the deterministic chapter plan.",
456
+ },
380
457
  {
381
458
  id: "toc_present",
382
459
  status: slides.some((slide) => slide.components.includes("toc")) ? "pass" : "blocker",
@@ -397,6 +474,16 @@ function checkPlanQuality(narrative: NarrativeStateV1, slides: SlideSpec[]): Dec
397
474
  status: narrative.claims.some((claim) => claim.importance === "central" && (claim.unsupportedScope || (claim.caveats ?? []).length > 0)) ? "warning" : "pass",
398
475
  message: narrative.claims.some((claim) => claim.importance === "central" && (claim.unsupportedScope || (claim.caveats ?? []).length > 0)) ? "Central claim boundaries are visible and should remain explicit in the rendered artifact." : "No unsupported central claim boundaries were found.",
399
476
  },
477
+ {
478
+ id: "evidence_required_claims_have_evidence_or_visible_gap",
479
+ status: invisibleEvidenceGaps.length === 0 ? evidenceRequiredWithoutBindings.length > 0 ? "warning" : "pass" : "blocker",
480
+ message: invisibleEvidenceGaps.length > 0 ? `Evidence-required claims missing from planned slides: ${invisibleEvidenceGaps.map((claim) => claim.id).join(", ")}` : evidenceRequiredWithoutBindings.length > 0 ? `Evidence gaps remain visible for claims: ${evidenceRequiredWithoutBindings.map((claim) => claim.id).join(", ")}` : "Every evidence-required claim has at least one evidence binding.",
481
+ },
482
+ {
483
+ id: "risk_or_objection_visible",
484
+ status: risksOrObjectionsVisible ? "pass" : "warning",
485
+ message: risksOrObjectionsVisible ? "Risks and objections are visible when present." : "Narrative risks or objections exist but no risk/objection slide is planned.",
486
+ },
400
487
  {
401
488
  id: "simplified_design_grammar",
402
489
  status: incompatibleComponents.length === 0 ? "pass" : "blocker",