@cyber-dash-tech/revela 0.12.0 → 0.14.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 (37) hide show
  1. package/README.md +16 -16
  2. package/README.zh-CN.md +16 -16
  3. package/lib/commands/brief.ts +63 -0
  4. package/lib/commands/edit.ts +7 -5
  5. package/lib/commands/help.ts +5 -3
  6. package/lib/commands/inspect.ts +7 -5
  7. package/lib/commands/narrative.ts +160 -0
  8. package/lib/decks-state.ts +33 -0
  9. package/lib/edit/prompt.ts +3 -0
  10. package/lib/inspect/prompt.ts +15 -2
  11. package/lib/inspect/requests.ts +21 -2
  12. package/lib/inspection-context/compile.ts +230 -10
  13. package/lib/inspection-context/match.ts +71 -1
  14. package/lib/inspection-context/project.ts +131 -8
  15. package/lib/inspection-context/result.ts +183 -0
  16. package/lib/narrative-state/coverage.ts +100 -0
  17. package/lib/narrative-state/display.ts +219 -0
  18. package/lib/narrative-state/executive-brief.ts +246 -0
  19. package/lib/narrative-state/hash.ts +9 -0
  20. package/lib/narrative-state/map-html.ts +348 -0
  21. package/lib/narrative-state/map.ts +282 -0
  22. package/lib/narrative-state/normalize.ts +54 -0
  23. package/lib/narrative-state/queries.ts +434 -0
  24. package/lib/narrative-state/readiness.ts +71 -1
  25. package/lib/narrative-state/render-plan.ts +44 -1
  26. package/lib/narrative-state/research-gaps.ts +191 -0
  27. package/lib/narrative-state/types.ts +33 -0
  28. package/lib/refine/server.ts +91 -13
  29. package/lib/workspace-state/evidence-status.ts +21 -1
  30. package/lib/workspace-state/graph.ts +56 -2
  31. package/lib/workspace-state/types.ts +10 -1
  32. package/package.json +1 -1
  33. package/plugin.ts +33 -2
  34. package/tools/decks.ts +86 -1
  35. package/tools/edit.ts +10 -8
  36. package/tools/inspection-result.ts +37 -0
  37. package/tools/narrative-view.ts +84 -0
@@ -1,5 +1,5 @@
1
1
  import type { NarrativeBrief, NarrativeRole } from "../decks-state"
2
- import type { InspectionContext, InspectionEvidenceTrace, InspectionGap } from "./compile"
2
+ import type { InspectionClaimCandidate, InspectionContext, InspectionEvidenceTrace, InspectionGap } from "./compile"
3
3
  import type { InspectionElementMatch, InspectionElementSnapshot, InspectionMatchConfidence } from "./match"
4
4
 
5
5
  export interface InspectionPromptProjection {
@@ -13,6 +13,7 @@ export interface InspectionPromptProjection {
13
13
  caveats: InspectionCaveatsProjection
14
14
  objective: InspectionObjectiveProjection
15
15
  appendix: InspectionAppendixProjection
16
+ artifacts: InspectionArtifactCoverageProjection
16
17
  }
17
18
  }
18
19
 
@@ -28,11 +29,15 @@ export interface InspectionProjectionElement {
28
29
  scope?: "element" | "selection" | "slide"
29
30
  slideIndex?: number
30
31
  text?: string
32
+ nearbyText?: string
33
+ outerHTMLExcerpt?: string
31
34
  elements?: Array<{
32
35
  text?: string
33
36
  tagName?: string
34
37
  classList: string[]
35
38
  role?: string
39
+ nearbyText?: string
40
+ outerHTMLExcerpt?: string
36
41
  }>
37
42
  tagName?: string
38
43
  classList: string[]
@@ -50,11 +55,30 @@ export interface InspectionProjectionMatch {
50
55
  }
51
56
  claim?: {
52
57
  id: string
58
+ canonicalClaimId?: string
53
59
  origin: string
54
60
  text: string
55
61
  evidenceSensitive: boolean
56
62
  evidenceSupport: string
63
+ evidenceBindingIds: string[]
64
+ supportedScope?: string
65
+ unsupportedScope?: string
66
+ caveats: string[]
57
67
  }
68
+ candidateClaims?: InspectionProjectionClaimCandidate[]
69
+ }
70
+
71
+ export interface InspectionProjectionClaimCandidate {
72
+ id: string
73
+ canonicalClaimId?: string
74
+ origin: string
75
+ text: string
76
+ evidenceSensitive: boolean
77
+ evidenceSupport: string
78
+ evidenceBindingIds: string[]
79
+ supportedScope?: string
80
+ unsupportedScope?: string
81
+ caveats: string[]
58
82
  }
59
83
 
60
84
  export interface InspectionSourceProjection {
@@ -95,8 +119,36 @@ export interface InspectionAppendixProjection {
95
119
  relatedObjections: string[]
96
120
  }
97
121
 
122
+ export interface InspectionArtifactCoverageProjection {
123
+ selectedClaimId?: string
124
+ artifacts: InspectionArtifactCoverageProjectionItem[]
125
+ }
126
+
127
+ export interface InspectionArtifactCoverageProjectionItem {
128
+ artifactId: string
129
+ type: string
130
+ outputPath?: string
131
+ coverageStatus: "current" | "stale" | "partial" | "missing"
132
+ containsClaim: boolean
133
+ stale: boolean
134
+ staleReason?: string
135
+ staleReasons: string[]
136
+ affectedClaimIds: string[]
137
+ missingClaimIds: string[]
138
+ note?: string
139
+ locations: Array<{
140
+ slideIndex: number
141
+ slideTitle: string
142
+ role: string
143
+ match: string
144
+ location: string
145
+ }>
146
+ }
147
+
98
148
  export interface InspectionEvidenceProjectionTrace {
99
149
  source: string
150
+ evidenceBindingId?: string
151
+ claimId?: string
100
152
  sourcePath?: string
101
153
  findingsFile?: string
102
154
  location?: string
@@ -104,6 +156,9 @@ export interface InspectionEvidenceProjectionTrace {
104
156
  url?: string
105
157
  quote?: string
106
158
  caveat?: string
159
+ supportScope?: string
160
+ unsupportedScope?: string
161
+ strength?: string
107
162
  extractedTextPath?: string
108
163
  extractedManifestPath?: string
109
164
  hasDetail: boolean
@@ -122,6 +177,7 @@ export function projectInspectionMatch(
122
177
  ): InspectionPromptProjection {
123
178
  const slide = match.slide
124
179
  const claim = match.claim
180
+ const candidateClaims = dedupeClaims(match.candidateClaims ?? [])
125
181
  const traces = match.evidence.map(projectEvidenceTrace)
126
182
  const gaps = match.gaps.map(projectGap)
127
183
  const narrativeBrief = context.narrativeBrief
@@ -139,11 +195,15 @@ export function projectInspectionMatch(
139
195
  slideIndex: snapshot.slideIndex,
140
196
  scope: snapshot.scope,
141
197
  text: truncateOptional(snapshot.selectedText || snapshot.text, 700),
198
+ nearbyText: truncateOptional(snapshot.nearbyText, 900),
199
+ outerHTMLExcerpt: truncateOptional(snapshot.outerHTMLExcerpt, 900),
142
200
  elements: snapshot.elements?.slice(0, 12).map((item) => ({
143
201
  text: truncateOptional(item.text, 320),
144
202
  tagName: truncateOptional(item.tagName, 40),
145
203
  classList: (item.classList ?? []).slice(0, 8).map((className) => truncate(className, 80)),
146
204
  role: truncateOptional(item.role, 80),
205
+ nearbyText: truncateOptional(item.nearbyText, 420),
206
+ outerHTMLExcerpt: truncateOptional(item.outerHTMLExcerpt, 420),
147
207
  })),
148
208
  tagName: truncateOptional(snapshot.tagName, 40),
149
209
  classList: (snapshot.classList ?? []).slice(0, 12).map((item) => truncate(item, 80)),
@@ -161,14 +221,9 @@ export function projectInspectionMatch(
161
221
  }
162
222
  : undefined,
163
223
  claim: claim
164
- ? {
165
- id: claim.id,
166
- origin: claim.origin,
167
- text: truncate(claim.text, 500),
168
- evidenceSensitive: claim.evidenceSensitive,
169
- evidenceSupport: claim.evidenceSupport,
170
- }
224
+ ? projectClaimCandidate(claim)
171
225
  : undefined,
226
+ candidateClaims: candidateClaims.map(projectClaimCandidate).slice(0, 8),
172
227
  },
173
228
  cards: {
174
229
  source: {
@@ -204,13 +259,78 @@ export function projectInspectionMatch(
204
259
  relatedRisks: relatedNarrativeText(context.riskContext, slide?.index),
205
260
  relatedObjections: relatedNarrativeText(context.objectionContext, slide?.index),
206
261
  },
262
+ artifacts: projectArtifactCoverage(context, claim?.canonicalClaimId ?? claim?.id),
207
263
  },
208
264
  }
209
265
  }
210
266
 
267
+ function projectClaimCandidate(claim: InspectionClaimCandidate): InspectionProjectionClaimCandidate {
268
+ return {
269
+ id: claim.id,
270
+ canonicalClaimId: claim.canonicalClaimId,
271
+ origin: claim.origin,
272
+ text: truncate(claim.text, 500),
273
+ evidenceSensitive: claim.evidenceSensitive,
274
+ evidenceSupport: claim.evidenceSupport,
275
+ evidenceBindingIds: claim.evidenceBindingIds,
276
+ supportedScope: truncateOptional(claim.supportedScope, 280),
277
+ unsupportedScope: truncateOptional(claim.unsupportedScope, 280),
278
+ caveats: claim.caveats.map((item) => truncate(item, 280)).slice(0, 8),
279
+ }
280
+ }
281
+
282
+ function dedupeClaims(claims: InspectionClaimCandidate[]): InspectionClaimCandidate[] {
283
+ const seen = new Set<string>()
284
+ const result: InspectionClaimCandidate[] = []
285
+ for (const claim of claims) {
286
+ const key = claim.canonicalClaimId || claim.id
287
+ if (seen.has(key)) continue
288
+ seen.add(key)
289
+ result.push(claim)
290
+ }
291
+ return result
292
+ }
293
+
294
+ function projectArtifactCoverage(context: InspectionContext, selectedClaimId: string | undefined): InspectionArtifactCoverageProjection {
295
+ return {
296
+ selectedClaimId,
297
+ artifacts: context.artifactCoverage.map((artifact) => {
298
+ const locations = selectedClaimId
299
+ ? artifact.slideRefs
300
+ .filter((ref) => ref.claimId === selectedClaimId)
301
+ .slice(0, 8)
302
+ .map((ref) => ({
303
+ slideIndex: ref.slideIndex,
304
+ slideTitle: truncate(ref.slideTitle, 180),
305
+ role: ref.role,
306
+ match: ref.match,
307
+ location: truncate(ref.location, 120),
308
+ }))
309
+ : []
310
+ const containsClaim = Boolean(selectedClaimId && (artifact.claimIds.includes(selectedClaimId) || locations.length > 0))
311
+ return {
312
+ artifactId: truncate(artifact.artifactId, 180),
313
+ type: artifact.type,
314
+ outputPath: truncateOptional(artifact.outputPath, 220),
315
+ coverageStatus: artifact.coverageStatus,
316
+ containsClaim,
317
+ stale: artifact.stale,
318
+ staleReason: truncateOptional(artifact.staleReason, 240),
319
+ staleReasons: artifact.staleReasons.map((item) => truncate(item, 240)).slice(0, 5),
320
+ affectedClaimIds: artifact.affectedClaimIds.map((item) => truncate(item, 160)).slice(0, 8),
321
+ missingClaimIds: artifact.missingClaimIds.map((item) => truncate(item, 160)).slice(0, 8),
322
+ note: truncateOptional(artifact.note, 240),
323
+ locations,
324
+ }
325
+ }).slice(0, 8),
326
+ }
327
+ }
328
+
211
329
  function projectEvidenceTrace(trace: InspectionEvidenceTrace): InspectionEvidenceProjectionTrace {
212
330
  return {
213
331
  source: truncate(trace.source, 180),
332
+ evidenceBindingId: truncateOptional(trace.evidenceBindingId, 160),
333
+ claimId: truncateOptional(trace.claimId, 160),
214
334
  sourcePath: truncateOptional(trace.sourcePath, 220),
215
335
  findingsFile: truncateOptional(trace.findingsFile, 220),
216
336
  location: truncateOptional(trace.location, 120),
@@ -218,6 +338,9 @@ function projectEvidenceTrace(trace: InspectionEvidenceTrace): InspectionEvidenc
218
338
  url: truncateOptional(trace.url, 240),
219
339
  quote: truncateOptional(trace.quote, 500),
220
340
  caveat: truncateOptional(trace.caveat, 280),
341
+ supportScope: truncateOptional(trace.supportScope, 280),
342
+ unsupportedScope: truncateOptional(trace.unsupportedScope, 280),
343
+ strength: trace.strength,
221
344
  extractedTextPath: truncateOptional(trace.extractedTextPath, 220),
222
345
  extractedManifestPath: truncateOptional(trace.extractedManifestPath, 220),
223
346
  hasDetail: trace.hasDetail,
@@ -4,6 +4,8 @@ import type { InspectionPromptProjection } from "./project"
4
4
  export type InspectionResultStatus = "success" | "no_match"
5
5
  export type InspectionPurposeStatus = "clear" | "weak" | "misplaced" | "unknown"
6
6
  export type InspectionSourceStatus = "supported" | "weak" | "unsupported" | "not_needed" | "unknown"
7
+ export type NarrativeReadingStatus = "matched" | "no_match"
8
+ export type ExploratoryReadingStatus = "available" | "limited" | "unavailable"
7
9
 
8
10
  export interface InspectionResult {
9
11
  version: 1
@@ -16,6 +18,8 @@ export interface InspectionResult {
16
18
  }
17
19
  matchConfidence: InspectionMatchConfidence
18
20
  cards: {
21
+ reading?: NarrativeReadingCard
22
+ exploratory?: ExploratoryReadingCard
19
23
  purpose: PurposeCard
20
24
  source: SourceCard
21
25
  }
@@ -53,6 +57,47 @@ export interface SourceCard {
53
57
  rationale: string
54
58
  }
55
59
 
60
+ export interface NarrativeReadingCard {
61
+ status: NarrativeReadingStatus
62
+ claimId?: string
63
+ canonicalClaimId?: string
64
+ claimText?: string
65
+ evidenceStatus?: string
66
+ evidenceBindingIds: string[]
67
+ supportedScope?: string
68
+ unsupportedScope?: string
69
+ caveats: string[]
70
+ relatedObjections: string[]
71
+ relatedRisks: string[]
72
+ artifactCoverage: NarrativeReadingArtifactCoverage[]
73
+ rationale: string
74
+ }
75
+
76
+ export interface NarrativeReadingArtifactCoverage {
77
+ artifactId: string
78
+ type: string
79
+ outputPath?: string
80
+ coverageStatus: "current" | "stale" | "partial" | "missing"
81
+ containsClaim: boolean
82
+ stale: boolean
83
+ staleReason?: string
84
+ locations: string[]
85
+ note?: string
86
+ }
87
+
88
+ export interface ExploratoryReadingCard {
89
+ status: ExploratoryReadingStatus
90
+ official: false
91
+ audience?: string
92
+ claimFocus?: string
93
+ objectionPrompts: string[]
94
+ audienceReframe: string
95
+ appendixLeads: string[]
96
+ meetingPrep: string[]
97
+ boundaries: string[]
98
+ rationale: string
99
+ }
100
+
56
101
  export function buildDeterministicInspectionResult(
57
102
  projection: InspectionPromptProjection,
58
103
  options: { requestId?: string; staleReason?: string } = {},
@@ -72,6 +117,8 @@ export function buildDeterministicInspectionResult(
72
117
  slide: slide ? { index: slide.index, title: slide.title } : undefined,
73
118
  matchConfidence: projection.match.confidence,
74
119
  cards: {
120
+ reading: narrativeReadingCard(projection, noMatch),
121
+ exploratory: exploratoryReadingCard(projection, noMatch),
75
122
  purpose: {
76
123
  status: purposeStatus(projection, noMatch),
77
124
  role: projection.cards.objective.narrativeRole,
@@ -101,6 +148,126 @@ export function buildDeterministicInspectionResult(
101
148
  }
102
149
  }
103
150
 
151
+ function exploratoryReadingCard(projection: InspectionPromptProjection, noMatch: boolean): ExploratoryReadingCard {
152
+ const claimText = projection.match.claim?.text ?? projection.cards.evidence.matchedClaim
153
+ const caveats = projection.cards.caveats.caveats
154
+ const unsupportedScope = projection.match.claim?.unsupportedScope ?? firstPresent(projection.cards.evidence.traces.map((item) => item.unsupportedScope))
155
+ const objectionPrompts = exploratoryObjections(projection, unsupportedScope)
156
+ const appendixLeads = exploratoryAppendixLeads(projection)
157
+ const meetingPrep = exploratoryMeetingPrep(projection, unsupportedScope)
158
+ return {
159
+ status: noMatch ? "unavailable" : claimText ? "available" : "limited",
160
+ official: false,
161
+ audience: projection.cards.objective.audience,
162
+ claimFocus: claimText,
163
+ objectionPrompts,
164
+ audienceReframe: exploratoryAudienceFrame(projection, claimText),
165
+ appendixLeads,
166
+ meetingPrep,
167
+ boundaries: exploratoryBoundaries(projection, noMatch),
168
+ rationale: exploratoryRationale(projection, noMatch, objectionPrompts.length + appendixLeads.length + meetingPrep.length),
169
+ }
170
+ }
171
+
172
+ function exploratoryObjections(projection: InspectionPromptProjection, unsupportedScope: string | undefined): string[] {
173
+ const items = projection.cards.appendix.relatedObjections.map((item) => `Prepare for this objection using recorded support only: ${item}`)
174
+ if (unsupportedScope) items.push(`Expect questions about unsupported scope: ${unsupportedScope}`)
175
+ for (const gap of projection.cards.evidence.gaps) items.push(`Treat this as an evidence gap, not as support: ${gap.message}`)
176
+ return dedupe(items).slice(0, 4)
177
+ }
178
+
179
+ function exploratoryAudienceFrame(projection: InspectionPromptProjection, claimText: string | undefined): string {
180
+ const audience = projection.cards.objective.audience
181
+ const beliefAfter = projection.cards.objective.audienceBeliefAfter
182
+ const decision = projection.cards.objective.decisionOrAction
183
+ if (!claimText) return "No specific claim is matched, so audience reframing is limited to the selected slide context."
184
+ if (audience && beliefAfter) return `For ${audience}, frame this claim as support for the desired belief: ${beliefAfter}`
185
+ if (audience && decision) return `For ${audience}, connect this claim to the recorded decision or action: ${decision}`
186
+ if (audience) return `For ${audience}, keep the wording inside the recorded claim boundary: ${claimText}`
187
+ return "Audience-specific reframing is limited because no audience is recorded in the projection."
188
+ }
189
+
190
+ function exploratoryAppendixLeads(projection: InspectionPromptProjection): string[] {
191
+ const sourceLeads = projection.cards.evidence.traces.map((trace) => {
192
+ const where = trace.location || trace.page || trace.url || trace.sourcePath || trace.findingsFile
193
+ return where ? `${trace.source}: ${where}` : trace.source
194
+ })
195
+ const appendixCandidates = projection.cards.appendix.candidates.map((candidate) => `Slide ${candidate.slideIndex}: ${candidate.slideTitle} (${candidate.reason})`)
196
+ return dedupe([...sourceLeads, ...appendixCandidates]).slice(0, 5)
197
+ }
198
+
199
+ function exploratoryMeetingPrep(projection: InspectionPromptProjection, unsupportedScope: string | undefined): string[] {
200
+ const items: string[] = []
201
+ for (const risk of projection.cards.appendix.relatedRisks) items.push(`Risk to be ready for: ${risk}`)
202
+ for (const caveat of projection.cards.caveats.caveats) items.push(`Caveat to say plainly: ${caveat}`)
203
+ if (unsupportedScope) items.push(`Do not overstate beyond: ${unsupportedScope}`)
204
+ for (const warning of projection.cards.source.weakSourceGaps) items.push(`Source trace is weak: ${warning.message}`)
205
+ for (const gap of projection.cards.source.missingSourceGaps) items.push(`Missing support: ${gap.message}`)
206
+ return dedupe(items).slice(0, 5)
207
+ }
208
+
209
+ function exploratoryBoundaries(projection: InspectionPromptProjection, noMatch: boolean): string[] {
210
+ const boundaries = [
211
+ "Exploratory reading is non-official and does not change canonical narrative state or artifact content.",
212
+ "Use only recorded claims, evidence, caveats, risks, objections, and artifact coverage from this inspection projection.",
213
+ ]
214
+ if (noMatch) boundaries.push("No matched slide or claim is available, so exploratory reading cannot infer support.")
215
+ if (projection.cards.evidence.gaps.length > 0) boundaries.push("Evidence gaps must remain visible and cannot be filled by generated reading text.")
216
+ return boundaries
217
+ }
218
+
219
+ function exploratoryRationale(projection: InspectionPromptProjection, noMatch: boolean, signalCount: number): string {
220
+ if (noMatch) return "Exploratory reading is unavailable because the selection did not match a slide or claim."
221
+ if (signalCount > 0) return "This bounded reading layer derives objection, audience, appendix, and meeting-prep cues from recorded inspection context only."
222
+ if (projection.match.claim) return "A matched claim exists, but little supporting exploratory context is recorded beyond the claim itself."
223
+ return "Exploratory reading is limited because the selection maps to slide context but not to a specific claim."
224
+ }
225
+
226
+ function narrativeReadingCard(projection: InspectionPromptProjection, noMatch: boolean): NarrativeReadingCard {
227
+ const claim = projection.match.claim
228
+ const evidenceBindingIds = dedupe([
229
+ ...(claim?.evidenceBindingIds ?? []),
230
+ ...projection.cards.evidence.traces.map((item) => item.evidenceBindingId).filter((item): item is string => Boolean(item)),
231
+ ])
232
+ return {
233
+ status: noMatch ? "no_match" : "matched",
234
+ claimId: claim?.id,
235
+ canonicalClaimId: claim?.canonicalClaimId,
236
+ claimText: claim?.text ?? projection.cards.evidence.matchedClaim,
237
+ evidenceStatus: claim?.evidenceSupport ?? projection.cards.evidence.evidenceSupport,
238
+ evidenceBindingIds,
239
+ supportedScope: claim?.supportedScope ?? firstPresent(projection.cards.evidence.traces.map((item) => item.supportScope)),
240
+ unsupportedScope: claim?.unsupportedScope ?? firstPresent(projection.cards.evidence.traces.map((item) => item.unsupportedScope)),
241
+ caveats: projection.cards.caveats.caveats,
242
+ relatedObjections: projection.cards.appendix.relatedObjections,
243
+ relatedRisks: projection.cards.appendix.relatedRisks,
244
+ artifactCoverage: artifactCoverage(projection),
245
+ rationale: narrativeReadingRationale(projection, noMatch),
246
+ }
247
+ }
248
+
249
+ function artifactCoverage(projection: InspectionPromptProjection): NarrativeReadingArtifactCoverage[] {
250
+ return projection.cards.artifacts.artifacts.map((artifact) => ({
251
+ artifactId: artifact.artifactId,
252
+ type: artifact.type,
253
+ outputPath: artifact.outputPath,
254
+ coverageStatus: artifact.coverageStatus,
255
+ containsClaim: artifact.containsClaim,
256
+ stale: artifact.stale,
257
+ staleReason: artifact.staleReason,
258
+ locations: artifact.locations.map((location) => `Slide ${location.slideIndex}: ${location.slideTitle} (${location.role}, ${location.match}:${location.location})`),
259
+ note: artifact.note ?? artifact.staleReasons[0],
260
+ }))
261
+ }
262
+
263
+ function narrativeReadingRationale(projection: InspectionPromptProjection, noMatch: boolean): string {
264
+ if (noMatch) return "No matched slide or claim is available for narrative reading."
265
+ const claim = projection.match.claim
266
+ if (claim?.canonicalClaimId) return "The selection resolves to a canonical narrative claim, so Refine can show claim, evidence-boundary, caveat, objection, and risk context before any generated judgment."
267
+ if (claim) return "The selection resolves to a slide claim candidate. Canonical narrative linkage is not recorded for this element, so reading context falls back to slide claim and source trace."
268
+ return "The selection resolves to a slide but not a specific claim; narrative reading is limited to slide purpose, role, and surrounding evidence context."
269
+ }
270
+
104
271
  function purposeStatus(projection: InspectionPromptProjection, noMatch: boolean): InspectionPurposeStatus {
105
272
  if (noMatch) return "unknown"
106
273
  if (projection.cards.objective.narrativeRole || projection.cards.objective.slidePurpose) return "clear"
@@ -158,3 +325,19 @@ function sourceCaveats(projection: InspectionPromptProjection): string[] {
158
325
  ...projection.cards.appendix.relatedObjections,
159
326
  ].slice(0, 10)
160
327
  }
328
+
329
+ function firstPresent(values: Array<string | undefined>): string | undefined {
330
+ return values.find((value) => Boolean(value?.trim()))
331
+ }
332
+
333
+ function dedupe(values: string[]): string[] {
334
+ const seen = new Set<string>()
335
+ const result: string[] = []
336
+ for (const value of values) {
337
+ const key = value.trim()
338
+ if (!key || seen.has(key)) continue
339
+ seen.add(key)
340
+ result.push(key)
341
+ }
342
+ return result
343
+ }
@@ -0,0 +1,100 @@
1
+ import { type DecksState, type SlideClaimRef, type SlideClaimRefRole } from "../decks-state"
2
+ import { recordWorkspaceAction } from "../workspace-state/actions"
3
+ import { ensureActiveHtmlDeckRenderTarget } from "../workspace-state/render-targets"
4
+ import { computeNarrativeHash } from "./hash"
5
+ import { normalizeNarrativeState } from "./normalize"
6
+ import { getClaimSlideRefs, type ClaimSlideRef } from "./queries"
7
+
8
+ export interface BackfillSlideClaimRefsResult {
9
+ updated: boolean
10
+ addedCount: number
11
+ slideCount: number
12
+ narrativeHash: string
13
+ refs: ClaimSlideRef[]
14
+ }
15
+
16
+ export function backfillSlideClaimRefsFromCoverage(state: DecksState): { state: DecksState; result: BackfillSlideClaimRefsResult } {
17
+ const narrative = normalizeNarrativeState(state)
18
+ const narrativeHash = computeNarrativeHash(narrative)
19
+ const deckKey = state.activeDeck || Object.keys(state.decks)[0]
20
+ const deck = deckKey ? state.decks[deckKey] : undefined
21
+ if (!deck) {
22
+ return { state: { ...state, narrative }, result: { updated: false, addedCount: 0, slideCount: 0, narrativeHash, refs: [] } }
23
+ }
24
+
25
+ const refs = getClaimSlideRefs({ ...state, narrative }, deck)
26
+ const refsBySlide = new Map<number, ClaimSlideRef[]>()
27
+ for (const ref of refs) refsBySlide.set(ref.slideIndex, [...(refsBySlide.get(ref.slideIndex) ?? []), ref])
28
+
29
+ let addedCount = 0
30
+ const slides = deck.slides.map((slide) => {
31
+ const existing = [...(slide.claimRefs ?? [])]
32
+ const seen = new Set(existing.map((ref) => `${ref.claimId}:${ref.role}`))
33
+ const additions: SlideClaimRef[] = []
34
+ for (const ref of refsBySlide.get(slide.index) ?? []) {
35
+ const role = backfilledRole(ref.role)
36
+ const key = `${ref.claimId}:${role}`
37
+ if (seen.has(key)) continue
38
+ seen.add(key)
39
+ additions.push({ claimId: ref.claimId, role, note: backfillNote(ref) })
40
+ }
41
+ if (additions.length === 0) return slide
42
+ addedCount += additions.length
43
+ return { ...slide, claimRefs: [...existing, ...additions] }
44
+ })
45
+
46
+ const next: DecksState = {
47
+ ...state,
48
+ narrative,
49
+ decks: {
50
+ ...state.decks,
51
+ [deckKey]: {
52
+ ...deck,
53
+ slides,
54
+ },
55
+ },
56
+ }
57
+
58
+ const updatedRefs = getClaimSlideRefs(next, next.decks[deckKey])
59
+ const htmlTarget = ensureActiveHtmlDeckRenderTarget(next)
60
+ if (htmlTarget) {
61
+ htmlTarget.data = {
62
+ ...(htmlTarget.data ?? {}),
63
+ narrativeId: narrative.id,
64
+ narrativeHash,
65
+ claimSlideRefs: updatedRefs.map((ref) => ({
66
+ claimId: ref.claimId,
67
+ claimText: ref.claimText,
68
+ slideIndex: ref.slideIndex,
69
+ slideTitle: ref.slideTitle,
70
+ match: ref.match,
71
+ role: ref.role,
72
+ location: ref.location,
73
+ })),
74
+ }
75
+ }
76
+
77
+ if (addedCount > 0) {
78
+ recordWorkspaceAction(next, {
79
+ type: "artifact.coverage_backfilled",
80
+ actor: "revela-decks",
81
+ inputs: { activeDeck: deckKey, narrativeId: narrative.id },
82
+ outputs: { addedCount, slideCount: slides.length, narrativeHash },
83
+ status: "success",
84
+ summary: `Backfilled ${addedCount} slide claim reference${addedCount === 1 ? "" : "s"} from current artifact coverage.`,
85
+ nodeIds: [narrative.id, `artifact:${deck.outputPath ?? deckKey}`],
86
+ })
87
+ }
88
+
89
+ return { state: next, result: { updated: addedCount > 0, addedCount, slideCount: slides.length, narrativeHash, refs: updatedRefs } }
90
+ }
91
+
92
+ function backfilledRole(role: SlideClaimRefRole): SlideClaimRefRole {
93
+ return role
94
+ }
95
+
96
+ function backfillNote(ref: ClaimSlideRef): string {
97
+ if (ref.match === "metadata") return `Backfilled from ${ref.location}.`
98
+ if (ref.match === "content") return `Backfilled from content match at ${ref.location}.`
99
+ return "Backfilled from slide evidence trace."
100
+ }