@cyber-dash-tech/revela 0.12.0 → 0.13.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.
@@ -1,4 +1,6 @@
1
1
  import { upsertDeck, upsertSlides, type DecksState, type EvidenceRef, type RequiredInputs, type SlideSpec } from "../decks-state"
2
+ import { ensureActiveHtmlDeckRenderTarget } from "../workspace-state/render-targets"
3
+ import { getClaimSlideRefs } from "./queries"
2
4
  import { computeNarrativeHash } from "./hash"
3
5
  import { normalizeNarrativeState } from "./normalize"
4
6
  import { narrativeToBrief } from "./project-compat"
@@ -64,6 +66,23 @@ export function compileDeckPlanFromNarrative(state: DecksState, options: Compile
64
66
  })
65
67
  next = upsertSlides(next, slug, slides)
66
68
  next.narrative = { ...narrative, updatedAt: options.now ?? narrative.updatedAt }
69
+ const htmlTarget = ensureActiveHtmlDeckRenderTarget(next)
70
+ if (htmlTarget) {
71
+ htmlTarget.data = {
72
+ ...(htmlTarget.data ?? {}),
73
+ narrativeId: narrative.id,
74
+ narrativeHash,
75
+ claimSlideRefs: getClaimSlideRefs(next).map((ref) => ({
76
+ claimId: ref.claimId,
77
+ claimText: ref.claimText,
78
+ slideIndex: ref.slideIndex,
79
+ slideTitle: ref.slideTitle,
80
+ match: ref.match,
81
+ role: ref.role,
82
+ location: ref.location,
83
+ })),
84
+ }
85
+ }
67
86
 
68
87
  return {
69
88
  state: next,
@@ -110,6 +129,7 @@ function buildSlides(narrative: NarrativeStateV1): SlideSpec[] {
110
129
 
111
130
  for (const claim of centralClaims) slides.push(claimSlide(slides.length + 1, claim, evidenceByClaim.get(claim.id) ?? []))
112
131
  if (supportingClaims.length > 0) {
132
+ const supportingBindings = supportingClaims.flatMap((claim) => evidenceByClaim.get(claim.id) ?? [])
113
133
  slides.push({
114
134
  index: slides.length + 1,
115
135
  title: "Supporting Logic",
@@ -118,16 +138,24 @@ function buildSlides(narrative: NarrativeStateV1): SlideSpec[] {
118
138
  layout: "card-grid",
119
139
  qa: true,
120
140
  components: ["card"],
141
+ claimIds: supportingClaims.map((claim) => claim.id),
142
+ claimRefs: supportingClaims.map((claim) => ({ claimId: claim.id, role: "supporting" as const })),
143
+ evidenceBindingIds: supportingBindings.map((binding) => binding.id),
121
144
  content: {
122
145
  headline: "Supporting claims and boundaries",
123
146
  bullets: supportingClaims.slice(0, 5).map((claim) => claim.text),
124
147
  },
125
- evidence: supportingClaims.flatMap((claim) => (evidenceByClaim.get(claim.id) ?? []).map(evidenceRefFromBinding)),
148
+ evidence: supportingBindings.map(evidenceRefFromBinding),
126
149
  status: "planned",
127
150
  })
128
151
  }
129
152
 
130
153
  if (narrative.risks.length > 0 || narrative.objections.length > 0) {
154
+ const challengedClaimRefs = [
155
+ ...narrative.risks.map((risk) => risk.claimId ? { claimId: risk.claimId, role: "risk" as const } : undefined).filter((ref): ref is { claimId: string; role: "risk" } => Boolean(ref)),
156
+ ...narrative.objections.map((objection) => objection.claimId ? { claimId: objection.claimId, role: "objection" as const } : undefined).filter((ref): ref is { claimId: string; role: "objection" } => Boolean(ref)),
157
+ ]
158
+ const challengedClaimIds = [...new Set(challengedClaimRefs.map((ref) => ref.claimId))]
131
159
  slides.push({
132
160
  index: slides.length + 1,
133
161
  title: "Risks And Objections",
@@ -136,6 +164,8 @@ function buildSlides(narrative: NarrativeStateV1): SlideSpec[] {
136
164
  layout: "two-col",
137
165
  qa: true,
138
166
  components: ["card"],
167
+ claimIds: challengedClaimIds,
168
+ claimRefs: dedupeClaimRefs(challengedClaimRefs),
139
169
  content: {
140
170
  headline: "What could break the recommendation",
141
171
  bullets: [
@@ -176,6 +206,9 @@ function claimSlide(index: number, claim: NarrativeClaim, bindings: NarrativeEvi
176
206
  layout: "two-col",
177
207
  qa: true,
178
208
  components: ["card"],
209
+ claimIds: [claim.id],
210
+ claimRefs: [{ claimId: claim.id, role: "primary" }],
211
+ evidenceBindingIds: bindings.map((binding) => binding.id),
179
212
  content: {
180
213
  headline: claim.text,
181
214
  bullets: [claim.supportedScope, claim.unsupportedScope ? `Unsupported scope: ${claim.unsupportedScope}` : undefined, ...(claim.caveats ?? [])].filter((item): item is string => Boolean(item)),
@@ -185,6 +218,16 @@ function claimSlide(index: number, claim: NarrativeClaim, bindings: NarrativeEvi
185
218
  }
186
219
  }
187
220
 
221
+ function dedupeClaimRefs<T extends { claimId: string; role: "risk" | "objection" }>(refs: T[]): T[] {
222
+ const seen = new Set<string>()
223
+ return refs.filter((ref) => {
224
+ const key = `${ref.claimId}:${ref.role}`
225
+ if (seen.has(key)) return false
226
+ seen.add(key)
227
+ return true
228
+ })
229
+ }
230
+
188
231
  function evidenceRefFromBinding(binding: NarrativeEvidenceBinding): EvidenceRef {
189
232
  return {
190
233
  source: binding.source,
@@ -0,0 +1,191 @@
1
+ import type { DecksState } from "../decks-state"
2
+ import { recordWorkspaceAction } from "../workspace-state/actions"
3
+ import { stableResearchGapId } from "./hash"
4
+ import { normalizeNarrativeState } from "./normalize"
5
+ import { reviewNarrativeState } from "./readiness"
6
+ import type {
7
+ NarrativeReadinessIssue,
8
+ NarrativeResearchGap,
9
+ NarrativeResearchGapStatus,
10
+ NarrativeResearchGapTargetType,
11
+ NarrativeStateV1,
12
+ } from "./types"
13
+
14
+ export interface UpsertResearchGapInput {
15
+ id?: string
16
+ targetType?: NarrativeResearchGapTargetType
17
+ targetId?: string
18
+ question: string
19
+ status?: NarrativeResearchGapStatus
20
+ priority?: "high" | "medium" | "low"
21
+ findingsFile?: string
22
+ evidenceBindingIds?: string[]
23
+ createdFromIssueType?: NarrativeReadinessIssue["type"]
24
+ notes?: string
25
+ }
26
+
27
+ export interface UpdateResearchGapInput {
28
+ id: string
29
+ status?: NarrativeResearchGapStatus
30
+ findingsFile?: string
31
+ evidenceBindingIds?: string[]
32
+ notes?: string
33
+ }
34
+
35
+ export interface ResearchGapMutationResult {
36
+ created: NarrativeResearchGap[]
37
+ updated: NarrativeResearchGap[]
38
+ skipped: Array<{ id?: string; question?: string; reason: string }>
39
+ gaps: NarrativeResearchGap[]
40
+ }
41
+
42
+ export interface CloseResearchGapResult {
43
+ closed: boolean
44
+ skipped: boolean
45
+ reason?: string
46
+ gap?: NarrativeResearchGap
47
+ }
48
+
49
+ export function deriveResearchGapsFromReadiness(state: DecksState, options: { now?: string } = {}): { state: DecksState; result: ResearchGapMutationResult } {
50
+ const reviewed = reviewNarrativeState(state, { now: options.now })
51
+ return upsertResearchGapsInState(reviewed.state, gapsFromIssues(reviewed.state.narrative!, reviewed.result.issues), options)
52
+ }
53
+
54
+ export function upsertResearchGapsInState(state: DecksState, inputs: UpsertResearchGapInput[], options: { now?: string } = {}): { state: DecksState; result: ResearchGapMutationResult } {
55
+ const now = options.now ?? new Date().toISOString()
56
+ const narrative = ensureNarrative(state)
57
+ const existing = new Map((narrative.researchGaps ?? []).map((gap) => [gap.id, gap]))
58
+ const created: NarrativeResearchGap[] = []
59
+ const updated: NarrativeResearchGap[] = []
60
+ const skipped: ResearchGapMutationResult["skipped"] = []
61
+
62
+ for (const input of inputs) {
63
+ const question = clean(input.question)
64
+ if (!question) {
65
+ skipped.push({ reason: "question is required" })
66
+ continue
67
+ }
68
+ const targetType = input.targetType ?? "narrative"
69
+ const targetId = clean(input.targetId)
70
+ const id = input.id?.trim() || stableResearchGapId([targetType, targetId, question].filter(Boolean).join("|"))
71
+ const prior = existing.get(id)
72
+ if (prior?.status === "closed") {
73
+ skipped.push({ id, question, reason: "matching research gap is already closed" })
74
+ continue
75
+ }
76
+
77
+ const next: NarrativeResearchGap = {
78
+ id,
79
+ targetType,
80
+ targetId,
81
+ question,
82
+ status: input.status ?? prior?.status ?? "open",
83
+ priority: input.priority ?? prior?.priority ?? "medium",
84
+ findingsFile: clean(input.findingsFile) || prior?.findingsFile,
85
+ evidenceBindingIds: mergeIds(prior?.evidenceBindingIds, input.evidenceBindingIds),
86
+ createdFromIssueType: input.createdFromIssueType ?? prior?.createdFromIssueType,
87
+ notes: clean(input.notes) || prior?.notes,
88
+ createdAt: prior?.createdAt ?? now,
89
+ updatedAt: now,
90
+ closedAt: input.status === "closed" ? now : prior?.closedAt,
91
+ }
92
+ existing.set(id, next)
93
+ if (prior) updated.push(next)
94
+ else created.push(next)
95
+ }
96
+
97
+ const gaps = [...existing.values()].sort((a, b) => gapSortValue(a) - gapSortValue(b) || a.question.localeCompare(b.question))
98
+ state.narrative = { ...narrative, researchGaps: gaps, updatedAt: now }
99
+ if (created.length > 0) recordResearchGapAction(state, "research.gap_created", created, now)
100
+ if (updated.length > 0) recordResearchGapAction(state, "research.gap_updated", updated, now)
101
+ return { state, result: { created, updated, skipped, gaps } }
102
+ }
103
+
104
+ export function updateResearchGapInState(state: DecksState, input: UpdateResearchGapInput, options: { now?: string } = {}): { state: DecksState; result: ResearchGapMutationResult } {
105
+ const narrative = ensureNarrative(state)
106
+ const gap = (narrative.researchGaps ?? []).find((item) => item.id === input.id)
107
+ if (!gap) return { state, result: { created: [], updated: [], skipped: [{ id: input.id, reason: "research gap not found" }], gaps: narrative.researchGaps ?? [] } }
108
+ return upsertResearchGapsInState(state, [{ ...gap, ...input, question: gap.question, targetType: gap.targetType, targetId: gap.targetId }], options)
109
+ }
110
+
111
+ export function closeResearchGapInState(state: DecksState, id: string, reason?: string, options: { now?: string } = {}): { state: DecksState; result: CloseResearchGapResult } {
112
+ const now = options.now ?? new Date().toISOString()
113
+ const narrative = ensureNarrative(state)
114
+ const gaps = narrative.researchGaps ?? []
115
+ const gap = gaps.find((item) => item.id === id)
116
+ if (!gap) return { state, result: { closed: false, skipped: true, reason: "research gap not found" } }
117
+ const closed: NarrativeResearchGap = { ...gap, status: "closed", notes: clean(reason) || gap.notes, updatedAt: now, closedAt: now }
118
+ state.narrative = { ...narrative, researchGaps: gaps.map((item) => item.id === id ? closed : item), updatedAt: now }
119
+ recordResearchGapAction(state, "research.gap_closed", [closed], now)
120
+ return { state, result: { closed: true, skipped: false, gap: closed } }
121
+ }
122
+
123
+ function gapsFromIssues(narrative: NarrativeStateV1, issues: NarrativeReadinessIssue[]): UpsertResearchGapInput[] {
124
+ return issues.flatMap((issue) => {
125
+ if (!researchableIssue(issue)) return []
126
+ const target = targetForIssue(narrative, issue)
127
+ return [{
128
+ targetType: target.type,
129
+ targetId: target.id,
130
+ question: questionForIssue(issue),
131
+ priority: issue.severity === "blocker" ? "high" : "medium",
132
+ status: "open",
133
+ createdFromIssueType: issue.type,
134
+ notes: issue.suggestedAction,
135
+ findingsFile: issue.source?.startsWith("researches/") ? issue.source : undefined,
136
+ }]
137
+ })
138
+ }
139
+
140
+ function researchableIssue(issue: NarrativeReadinessIssue): boolean {
141
+ return issue.type === "missing_evidence" || issue.type === "weak_evidence" || issue.type === "unsupported_scope" || issue.type === "unhandled_objection" || issue.type === "missing_risk"
142
+ }
143
+
144
+ function targetForIssue(narrative: NarrativeStateV1, issue: NarrativeReadinessIssue): { type: NarrativeResearchGapTargetType; id?: string } {
145
+ if (issue.claimId) return { type: "claim", id: issue.claimId }
146
+ const objection = narrative.objections.find((item) => item.text === issue.claimText)
147
+ if (objection) return { type: "objection", id: objection.id }
148
+ if (issue.type === "missing_risk") return { type: "decision", id: narrative.decision.action ? stableResearchGapId(`decision:${narrative.decision.action}`) : undefined }
149
+ return { type: "narrative", id: narrative.id }
150
+ }
151
+
152
+ function questionForIssue(issue: NarrativeReadinessIssue): string {
153
+ if (issue.claimText && issue.type === "missing_evidence") return `Find evidence for claim: ${issue.claimText}`
154
+ if (issue.claimText && issue.type === "weak_evidence") return `Strengthen evidence for claim: ${issue.claimText}`
155
+ if (issue.claimText && issue.type === "unsupported_scope") return `Resolve unsupported scope for claim: ${issue.claimText}`
156
+ if (issue.type === "unhandled_objection") return `Find response or evidence for objection: ${issue.claimText ?? issue.message}`
157
+ if (issue.type === "missing_risk") return "Identify risk, assumption, caveat, or tradeoff handling for the recommendation."
158
+ return issue.message
159
+ }
160
+
161
+ function ensureNarrative(state: DecksState): NarrativeStateV1 {
162
+ state.narrative = normalizeNarrativeState(state)
163
+ return state.narrative
164
+ }
165
+
166
+ function recordResearchGapAction(state: DecksState, type: "research.gap_created" | "research.gap_updated" | "research.gap_closed", gaps: NarrativeResearchGap[], timestamp: string): void {
167
+ recordWorkspaceAction(state, {
168
+ type,
169
+ actor: "revela-decks",
170
+ timestamp,
171
+ inputs: { narrativeId: state.narrative?.id },
172
+ outputs: { gaps: gaps.map((gap) => ({ id: gap.id, status: gap.status, targetType: gap.targetType, targetId: gap.targetId, findingsFile: gap.findingsFile, evidenceBindingIds: gap.evidenceBindingIds })) },
173
+ status: "success",
174
+ summary: `${type === "research.gap_created" ? "Created" : type === "research.gap_closed" ? "Closed" : "Updated"} ${gaps.length} research gap${gaps.length === 1 ? "" : "s"}.`,
175
+ nodeIds: gaps.map((gap) => gap.id),
176
+ })
177
+ }
178
+
179
+ function gapSortValue(gap: NarrativeResearchGap): number {
180
+ const statusValue: Record<NarrativeResearchGapStatus, number> = { open: 0, in_progress: 1, findings_saved: 2, attached: 3, evidence_bound: 4, closed: 5 }
181
+ const priorityValue = gap.priority === "high" ? 0 : gap.priority === "medium" ? 1 : 2
182
+ return statusValue[gap.status] * 10 + priorityValue
183
+ }
184
+
185
+ function mergeIds(existing: string[] | undefined, next: string[] | undefined): string[] {
186
+ return [...new Set([...(existing ?? []), ...(next ?? [])].map(clean).filter(Boolean))].sort()
187
+ }
188
+
189
+ function clean(value: string | undefined): string {
190
+ return value?.trim() ?? ""
191
+ }
@@ -4,6 +4,12 @@ export type NarrativeClaimKind = "context" | "problem" | "opportunity" | "eviden
4
4
 
5
5
  export type NarrativeEvidenceStatus = "supported" | "partial" | "weak" | "missing" | "not_required"
6
6
 
7
+ export type NarrativeResearchGapStatus = "open" | "in_progress" | "findings_saved" | "attached" | "evidence_bound" | "closed"
8
+
9
+ export type NarrativeResearchGapTargetType = "claim" | "objection" | "risk" | "decision" | "narrative"
10
+
11
+ export type NarrativeClaimRelationType = "leads_to" | "supports" | "depends_on" | "contrasts_with" | "constrains" | "answers"
12
+
7
13
  export interface NarrativeStateV1 {
8
14
  version: 1
9
15
  id: string
@@ -12,9 +18,11 @@ export interface NarrativeStateV1 {
12
18
  decision: DecisionIntent
13
19
  thesis?: NarrativeThesis
14
20
  claims: NarrativeClaim[]
21
+ claimRelations?: NarrativeClaimRelation[]
15
22
  evidenceBindings: NarrativeEvidenceBinding[]
16
23
  objections: NarrativeObjection[]
17
24
  risks: NarrativeRisk[]
25
+ researchGaps?: NarrativeResearchGap[]
18
26
  approvals: NarrativeApproval[]
19
27
  updatedAt: string
20
28
  }
@@ -55,6 +63,14 @@ export interface NarrativeClaim {
55
63
  caveats?: string[]
56
64
  }
57
65
 
66
+ export interface NarrativeClaimRelation {
67
+ id: string
68
+ fromClaimId: string
69
+ toClaimId: string
70
+ relation: NarrativeClaimRelationType
71
+ rationale?: string
72
+ }
73
+
58
74
  export interface NarrativeEvidenceBinding {
59
75
  id: string
60
76
  claimId: string
@@ -86,6 +102,22 @@ export interface NarrativeRisk {
86
102
  mitigation?: string
87
103
  }
88
104
 
105
+ export interface NarrativeResearchGap {
106
+ id: string
107
+ targetType: NarrativeResearchGapTargetType
108
+ targetId?: string
109
+ question: string
110
+ status: NarrativeResearchGapStatus
111
+ priority: "high" | "medium" | "low"
112
+ findingsFile?: string
113
+ evidenceBindingIds?: string[]
114
+ createdFromIssueType?: NarrativeReadinessIssueType
115
+ notes?: string
116
+ createdAt: string
117
+ updatedAt: string
118
+ closedAt?: string
119
+ }
120
+
89
121
  export interface NarrativeApproval {
90
122
  id: string
91
123
  narrativeHash: string
@@ -112,6 +144,7 @@ export type NarrativeReadinessIssueType =
112
144
  | "approval_stale"
113
145
  | "artifact_stale"
114
146
  | "research_findings_unattached"
147
+ | "research_gap_open"
115
148
 
116
149
  export interface NarrativeReadinessIssue {
117
150
  type: NarrativeReadinessIssueType
@@ -40,15 +40,25 @@ export interface EvidenceStatusMatch {
40
40
  slideIndex?: number
41
41
  slideTitle?: string
42
42
  claimId?: string
43
+ canonicalClaimId?: string
43
44
  claimText?: string
44
45
  claimEvidenceSensitive?: boolean
45
46
  claimEvidenceSupport?: string
47
+ evidenceBindingIds: string[]
48
+ supportedScope?: string
49
+ unsupportedScope?: string
50
+ caveats: string[]
46
51
  }
47
52
 
48
53
  export interface EvidenceStatusEvidence extends EvidenceRef {
49
54
  slideIndex: number
50
55
  slideTitle: string
51
56
  hasDetail: boolean
57
+ evidenceBindingId?: string
58
+ claimId?: string
59
+ supportScope?: string
60
+ unsupportedScope?: string
61
+ strength?: "strong" | "partial" | "weak"
52
62
  }
53
63
 
54
64
  export interface EvidenceStatusGap {
@@ -159,9 +169,14 @@ function projectMatch(match: InspectionElementMatch): EvidenceStatusMatch {
159
169
  slideIndex: match.slide?.index,
160
170
  slideTitle: match.slide?.title,
161
171
  claimId: match.claim?.id,
172
+ canonicalClaimId: match.claim?.canonicalClaimId,
162
173
  claimText: match.claim?.text,
163
174
  claimEvidenceSensitive: match.claim?.evidenceSensitive,
164
175
  claimEvidenceSupport: match.claim?.evidenceSupport,
176
+ evidenceBindingIds: match.claim?.evidenceBindingIds ?? [],
177
+ supportedScope: match.claim?.supportedScope,
178
+ unsupportedScope: match.claim?.unsupportedScope,
179
+ caveats: match.claim?.caveats ?? [],
165
180
  }
166
181
  }
167
182
 
@@ -235,7 +250,12 @@ function relevantSearchDiagnostics(issues: ReadinessIssue[], match: InspectionEl
235
250
 
236
251
  function actionTraceForMatch(actions: WorkspaceAction[], match: InspectionElementMatch): EvidenceStatusActionTrace[] {
237
252
  const slideNodeId = match.slide ? `slide:${match.slide.index}` : undefined
238
- const evidenceKeys = new Set(match.evidence.flatMap((item) => [item.source, item.sourcePath, item.findingsFile].filter((value): value is string => Boolean(value))))
253
+ const evidenceKeys = new Set([
254
+ match.claim?.id,
255
+ match.claim?.canonicalClaimId,
256
+ ...(match.claim?.evidenceBindingIds ?? []),
257
+ ...match.evidence.flatMap((item) => [item.source, item.sourcePath, item.findingsFile, item.evidenceBindingId, item.claimId]),
258
+ ].filter((value): value is string => Boolean(value)))
239
259
  return actions
240
260
  .filter((action) => actionRelevantToMatch(action, slideNodeId, evidenceKeys))
241
261
  .slice(-12)
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "crypto"
2
2
  import type { DeckSpec, DecksState, EvidenceRef, NarrativeBrief, ResearchAxis, SlideSpec, SourceMaterial } from "../decks-state"
3
- import type { NarrativeEvidenceBinding, NarrativeStateV1 } from "../narrative-state/types"
3
+ import type { NarrativeClaimRelation, NarrativeEvidenceBinding, NarrativeResearchGap, NarrativeStateV1 } from "../narrative-state/types"
4
4
  import { renderTargetId } from "./render-targets"
5
5
  import type { GraphEdge, GraphEdgeType, GraphNode, GraphNodeType, RenderTarget, WorkspaceGraph } from "./types"
6
6
 
@@ -178,6 +178,8 @@ function addCanonicalNarrative(builder: GraphBuilder, narrative: NarrativeStateV
178
178
  }))
179
179
  }
180
180
 
181
+ for (const relation of narrative.claimRelations ?? []) addClaimRelation(builder, relation)
182
+
181
183
  for (const objection of narrative.objections) {
182
184
  addNode(builder, {
183
185
  id: objection.id,
@@ -200,6 +202,55 @@ function addCanonicalNarrative(builder: GraphBuilder, narrative: NarrativeStateV
200
202
  addEdge(builder, "constrained_by", risk.claimId || narrative.id, risk.id)
201
203
  }
202
204
 
205
+ for (const gap of narrative.researchGaps ?? []) addResearchGap(builder, narrative, gap)
206
+
207
+ return narrative.id
208
+ }
209
+
210
+ function addClaimRelation(builder: GraphBuilder, relation: NarrativeClaimRelation): void {
211
+ addEdge(builder, graphEdgeTypeForClaimRelation(relation.relation), relation.fromClaimId, relation.toClaimId, compactData({
212
+ relationId: relation.id,
213
+ relation: relation.relation,
214
+ rationale: relation.rationale,
215
+ source: "canonicalNarrative",
216
+ }))
217
+ }
218
+
219
+ function graphEdgeTypeForClaimRelation(relation: NarrativeClaimRelation["relation"]): GraphEdgeType {
220
+ if (relation === "constrains") return "constrained_by"
221
+ if (relation === "supports") return "supports"
222
+ return relation
223
+ }
224
+
225
+ function addResearchGap(builder: GraphBuilder, narrative: NarrativeStateV1, gap: NarrativeResearchGap): void {
226
+ addNode(builder, {
227
+ id: gap.id,
228
+ type: "researchGap",
229
+ label: gap.question,
230
+ data: compactData({
231
+ question: gap.question,
232
+ status: gap.status,
233
+ priority: gap.priority,
234
+ targetType: gap.targetType,
235
+ targetId: gap.targetId,
236
+ findingsFile: gap.findingsFile,
237
+ evidenceBindingIds: gap.evidenceBindingIds,
238
+ createdFromIssueType: gap.createdFromIssueType,
239
+ notes: gap.notes,
240
+ }),
241
+ })
242
+ addEdge(builder, "contains", narrative.id, gap.id)
243
+ addEdge(builder, "derived_from", gap.id, gapTargetNodeId(narrative, gap))
244
+ if (gap.findingsFile) addEdge(builder, "derived_from", gap.id, findingNodeId(gap.findingsFile), { status: gap.status })
245
+ for (const evidenceId of gap.evidenceBindingIds ?? []) {
246
+ const binding = narrative.evidenceBindings.find((item) => item.id === evidenceId)
247
+ if (binding) addEdge(builder, "derived_from", gap.id, binding.claimId, { evidenceBindingId: evidenceId })
248
+ }
249
+ }
250
+
251
+ function gapTargetNodeId(narrative: NarrativeStateV1, gap: NarrativeResearchGap): string {
252
+ if (gap.targetId) return gap.targetId
253
+ if (gap.targetType === "narrative" || gap.targetType === "decision") return narrative.id
203
254
  return narrative.id
204
255
  }
205
256
 
@@ -352,9 +403,11 @@ function renderTargetsForDeck(state: DecksState, deck: DeckSpec): RenderTarget[]
352
403
  const deckOutputPath = normalizePath(deck.outputPath)
353
404
  const htmlTargetId = renderTargetId("html_deck", deckOutputPath)
354
405
  const htmlArtifactId = artifactNodeId(deckOutputPath)
406
+ const narrativeId = state.narrative?.id
355
407
  const targets = (state.renderTargets ?? []).filter((target) => {
356
408
  if (target.id === htmlTargetId) return true
357
409
  if (target.type === "html_deck") return normalizePath(target.outputPath ?? "") === deckOutputPath
410
+ if (narrativeId && target.sourceNodeIds.includes(narrativeId)) return true
358
411
  const data = target.data ?? {}
359
412
  return data.sourceTargetId === htmlTargetId ||
360
413
  data.sourceOutputPath === deckOutputPath ||
@@ -422,7 +475,8 @@ function hasCanonicalNarrative(narrative: NarrativeStateV1 | undefined): narrati
422
475
  narrative?.claims.length ||
423
476
  narrative?.evidenceBindings.length ||
424
477
  narrative?.objections.length ||
425
- narrative?.risks.length,
478
+ narrative?.risks.length ||
479
+ narrative?.researchGaps?.length,
426
480
  )
427
481
  }
428
482
 
@@ -50,6 +50,7 @@ export type GraphNodeType =
50
50
  | "risk"
51
51
  | "slide"
52
52
  | "artifact"
53
+ | "researchGap"
53
54
 
54
55
  export interface GraphEdge {
55
56
  id: string
@@ -64,6 +65,10 @@ export type GraphEdgeType =
64
65
  | "extracted_as"
65
66
  | "produced"
66
67
  | "supports"
68
+ | "leads_to"
69
+ | "depends_on"
70
+ | "contrasts_with"
71
+ | "answers"
67
72
  | "appears_in"
68
73
  | "challenges"
69
74
  | "constrained_by"
@@ -88,8 +93,12 @@ export type WorkspaceActionType =
88
93
  | "source.extracted"
89
94
  | "research.findings_saved"
90
95
  | "research.findings_attached"
96
+ | "research.gap_created"
97
+ | "research.gap_updated"
98
+ | "research.gap_closed"
91
99
  | "narrative.upserted"
92
100
  | "deck.plan_compiled"
101
+ | "artifact.coverage_backfilled"
93
102
  | "evidence.candidate_generated"
94
103
  | "evidence.binding_applied"
95
104
  | "narrative.approved"
@@ -98,7 +107,7 @@ export type WorkspaceActionType =
98
107
 
99
108
  export interface RenderTarget {
100
109
  id: string
101
- type: "html_deck" | "pdf" | "pptx" | "brief" | "appendix" | "qa_view" | "interactive_page"
110
+ type: "html_deck" | "pdf" | "pptx" | "brief" | "executive_brief" | "appendix" | "qa_view" | "interactive_page"
102
111
  outputPath?: string
103
112
  sourceNodeIds: string[]
104
113
  artifactVersion?: string
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "OpenCode plugin that turns AI into an HTML slide deck generator",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
package/plugin.ts CHANGED
@@ -59,6 +59,8 @@ import {
59
59
  buildDesignsEditPrompt,
60
60
  } from "./lib/commands/designs-new"
61
61
  import { buildInitPrompt } from "./lib/commands/init"
62
+ import { handleBrief, parseBriefArgs } from "./lib/commands/brief"
63
+ import { buildNarrativeViewPrompt, handleNarrative, parseNarrativeArgs } from "./lib/commands/narrative"
62
64
  import { parseRememberArgs, buildRememberPrompt } from "./lib/commands/remember"
63
65
  import { buildDeckPrompt, buildDeckReviewPrompt, buildReviewPrompt } from "./lib/commands/review"
64
66
  import {
@@ -85,6 +87,7 @@ import researchImagesListTool from "./tools/research-images-list"
85
87
  import researchSaveTool from "./tools/research-save"
86
88
  import inspectionContextTool from "./tools/inspection-context"
87
89
  import inspectionResultTool from "./tools/inspection-result"
90
+ import narrativeViewTool from "./tools/narrative-view"
88
91
  import workspaceScanTool from "./tools/workspace-scan"
89
92
  import extractDocumentMaterialsTool from "./tools/extract-document-materials"
90
93
  import qaTool from "./tools/qa"
@@ -361,6 +364,33 @@ const server: Plugin = (async (pluginCtx) => {
361
364
  } as any)
362
365
  return
363
366
  }
367
+ if (sub === "narrative") {
368
+ const parsed = parseNarrativeArgs(param)
369
+ if (!parsed.ok) {
370
+ await send(parsed.error)
371
+ throw new Error("__REVELA_NARRATIVE_USAGE_HANDLED__")
372
+ }
373
+ if (parsed.args.raw) {
374
+ await handleNarrative({ workspaceRoot, openBrowser: true, language: parsed.args.language }, send)
375
+ throw new Error("__REVELA_NARRATIVE_HANDLED__")
376
+ }
377
+ buildPrompt({ mode: "narrative" })
378
+ output.parts.length = 0
379
+ output.parts.push({
380
+ type: "text",
381
+ text: buildNarrativeViewPrompt({ workspaceRoot, language: parsed.args.language }),
382
+ } as any)
383
+ return
384
+ }
385
+ if (sub === "brief") {
386
+ const parsed = parseBriefArgs(param)
387
+ if (!parsed.ok) {
388
+ await send(parsed.error)
389
+ throw new Error("__REVELA_BRIEF_USAGE_HANDLED__")
390
+ }
391
+ await handleBrief({ workspaceRoot, outputPath: parsed.args.outputPath }, send)
392
+ throw new Error("__REVELA_BRIEF_HANDLED__")
393
+ }
364
394
  if (sub === "deck") {
365
395
  if (param && param !== "--review") {
366
396
  await send("Usage: `/revela deck` starts approved-narrative deck handoff; `/revela deck --review` reviews deck/artifact readiness.")
@@ -507,6 +537,7 @@ const server: Plugin = (async (pluginCtx) => {
507
537
  "revela-research-save": researchSaveTool,
508
538
  "revela-inspection-context": inspectionContextTool,
509
539
  "revela-inspection-result": inspectionResultTool,
540
+ "revela-narrative-view": narrativeViewTool,
510
541
  "revela-workspace-scan": workspaceScanTool,
511
542
  "revela-extract-document-materials": extractDocumentMaterialsTool,
512
543
  "revela-qa": qaTool,