@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
@@ -0,0 +1,219 @@
1
+ import type { NarrativeMap, NarrativeMapClaimRelation } from "./map"
2
+
3
+ export type NarrativeViewLanguage = string
4
+
5
+ export interface NarrativeDisplayModel {
6
+ version: 1
7
+ language: NarrativeViewLanguage
8
+ pageTitle?: string
9
+ summaryLine?: string
10
+ labels?: Partial<NarrativeDisplayLabels>
11
+ claimCards?: NarrativeDisplayClaimCard[]
12
+ relations?: NarrativeDisplayRelation[]
13
+ }
14
+
15
+ export interface NarrativeDisplayLabels {
16
+ eyebrow: string
17
+ claimFlow: string
18
+ flowNote: string
19
+ selectedClaim: string
20
+ claim: string
21
+ claimId: string
22
+ status: string
23
+ supportedScope: string
24
+ unsupportedScope: string
25
+ incomingRelations: string
26
+ outgoingRelations: string
27
+ evidence: string
28
+ objections: string
29
+ risks: string
30
+ researchGaps: string
31
+ coveredSlides: string
32
+ noClaims: string
33
+ none: string
34
+ }
35
+
36
+ export interface NarrativeDisplayClaimCard {
37
+ claimId: string
38
+ displayTitle?: string
39
+ roleLabel?: string
40
+ narrativeJob?: string
41
+ evidenceSummary?: string
42
+ riskOrGapSummary?: string
43
+ }
44
+
45
+ export interface NarrativeDisplayRelation {
46
+ fromClaimId: string
47
+ toClaimId: string
48
+ relation: NarrativeMapClaimRelation["relation"]
49
+ displayLabel?: string
50
+ displayRationale?: string
51
+ }
52
+
53
+ export interface ValidatedNarrativeDisplayModel {
54
+ version: 1
55
+ language: NarrativeViewLanguage
56
+ pageTitle?: string
57
+ summaryLine?: string
58
+ labels: NarrativeDisplayLabels
59
+ claimCards: Map<string, NarrativeDisplayClaimCard>
60
+ relations: Map<string, NarrativeDisplayRelation>
61
+ }
62
+
63
+ export function defaultNarrativeDisplayLabels(language: NarrativeViewLanguage): NarrativeDisplayLabels {
64
+ if (isChineseLanguage(language)) {
65
+ return {
66
+ eyebrow: "只读主张流",
67
+ claimFlow: "主张推进",
68
+ flowNote: "点击主张查看证据、关系、风险、缺口和已覆盖页面。",
69
+ selectedClaim: "当前主张",
70
+ claim: "主张",
71
+ claimId: "主张 ID",
72
+ status: "状态",
73
+ supportedScope: "已支持范围",
74
+ unsupportedScope: "未支持范围",
75
+ incomingRelations: "前置关系",
76
+ outgoingRelations: "后续关系",
77
+ evidence: "证据",
78
+ objections: "反对意见",
79
+ risks: "风险",
80
+ researchGaps: "研究缺口",
81
+ coveredSlides: "已覆盖页面",
82
+ noClaims: "没有记录主张",
83
+ none: "无",
84
+ }
85
+ }
86
+ if (isJapaneseLanguage(language)) {
87
+ return {
88
+ eyebrow: "読み取り専用クレームフロー",
89
+ claimFlow: "クレームフロー",
90
+ flowNote: "クレームをクリックすると、根拠、関係、リスク、ギャップ、該当スライドを確認できます。",
91
+ selectedClaim: "選択中のクレーム",
92
+ claim: "クレーム",
93
+ claimId: "クレーム ID",
94
+ status: "ステータス",
95
+ supportedScope: "裏付けられた範囲",
96
+ unsupportedScope: "未裏付けの範囲",
97
+ incomingRelations: "入力関係",
98
+ outgoingRelations: "出力関係",
99
+ evidence: "根拠",
100
+ objections: "反論",
101
+ risks: "リスク",
102
+ researchGaps: "調査ギャップ",
103
+ coveredSlides: "対応スライド",
104
+ noClaims: "クレームは記録されていません",
105
+ none: "なし",
106
+ }
107
+ }
108
+ return {
109
+ eyebrow: "Read-only claim flow board",
110
+ claimFlow: "Claim Flow",
111
+ flowNote: "Click a claim to inspect support, relation context, gaps, and covered slides.",
112
+ selectedClaim: "Selected claim",
113
+ claim: "Claim",
114
+ claimId: "Claim ID",
115
+ status: "Status",
116
+ supportedScope: "Supported scope",
117
+ unsupportedScope: "Unsupported scope",
118
+ incomingRelations: "Incoming relations",
119
+ outgoingRelations: "Outgoing relations",
120
+ evidence: "Evidence",
121
+ objections: "Objections",
122
+ risks: "Risks",
123
+ researchGaps: "Research gaps",
124
+ coveredSlides: "Covered slides",
125
+ noClaims: "No claims recorded",
126
+ none: "None",
127
+ }
128
+ }
129
+
130
+ export function validateNarrativeDisplayModel(map: NarrativeMap, input: NarrativeDisplayModel | undefined, language: NarrativeViewLanguage): ValidatedNarrativeDisplayModel {
131
+ const defaults = defaultNarrativeDisplayLabels(language)
132
+ if (!input) return emptyDisplayModel(language, defaults)
133
+ if (input.version !== 1) throw new Error("Narrative display model version must be 1.")
134
+ if (input.language !== language) throw new Error(`Narrative display model language must be ${language}.`)
135
+
136
+ const claimIds = new Set(map.claimFlow.map((claim) => claim.id))
137
+ const relationByKey = new Map(map.claimRelations.map((relation) => [relationKey(relation), relation]))
138
+ const claimCards = new Map<string, NarrativeDisplayClaimCard>()
139
+ for (const card of input.claimCards ?? []) {
140
+ if (!claimIds.has(card.claimId)) throw new Error(`Unknown display claimId: ${card.claimId}`)
141
+ claimCards.set(card.claimId, cleanClaimCard(card))
142
+ }
143
+
144
+ const relations = new Map<string, NarrativeDisplayRelation>()
145
+ for (const relation of input.relations ?? []) {
146
+ const key = relationKey(relation)
147
+ const canonical = relationByKey.get(key)
148
+ if (!canonical) throw new Error(`Display relation is not present in the narrative map: ${key}`)
149
+ relations.set(key, cleanRelation(relation, canonical))
150
+ }
151
+
152
+ return {
153
+ version: 1,
154
+ language,
155
+ pageTitle: clean(input.pageTitle),
156
+ summaryLine: clean(input.summaryLine),
157
+ labels: mergeLabels(defaults, input.labels),
158
+ claimCards,
159
+ relations,
160
+ }
161
+ }
162
+
163
+ export function emptyDisplayModel(language: NarrativeViewLanguage, labels = defaultNarrativeDisplayLabels(language)): ValidatedNarrativeDisplayModel {
164
+ return { version: 1, language, labels, claimCards: new Map(), relations: new Map() }
165
+ }
166
+
167
+ export function relationKey(relation: Pick<NarrativeDisplayRelation, "fromClaimId" | "toClaimId" | "relation">): string {
168
+ return `${relation.fromClaimId}\u0000${relation.toClaimId}\u0000${relation.relation}`
169
+ }
170
+
171
+ export function isChineseLanguage(language: string): boolean {
172
+ const normalized = language.trim().toLowerCase()
173
+ return normalized === "zh" || normalized === "zh-cn" || normalized === "cn" || normalized === "chinese" || normalized.includes("中文") || normalized.includes("简体")
174
+ }
175
+
176
+ export function isJapaneseLanguage(language: string): boolean {
177
+ const normalized = language.trim().toLowerCase()
178
+ return normalized === "ja" || normalized === "ja-jp" || normalized === "jp" || normalized === "japanese" || normalized.includes("日本")
179
+ }
180
+
181
+ function mergeLabels(defaults: NarrativeDisplayLabels, overrides: Partial<NarrativeDisplayLabels> | undefined): NarrativeDisplayLabels {
182
+ const merged: NarrativeDisplayLabels = { ...defaults }
183
+ if (!overrides) return merged
184
+ for (const key of Object.keys(defaults) as Array<keyof NarrativeDisplayLabels>) {
185
+ merged[key] = clean(overrides[key]) ?? defaults[key]
186
+ }
187
+ return merged
188
+ }
189
+
190
+ function cleanClaimCard(card: NarrativeDisplayClaimCard): NarrativeDisplayClaimCard {
191
+ return {
192
+ claimId: card.claimId,
193
+ displayTitle: clean(card.displayTitle),
194
+ roleLabel: clean(card.roleLabel),
195
+ narrativeJob: clean(card.narrativeJob),
196
+ evidenceSummary: clean(card.evidenceSummary),
197
+ riskOrGapSummary: clean(card.riskOrGapSummary),
198
+ }
199
+ }
200
+
201
+ function cleanRelation(relation: NarrativeDisplayRelation, canonical: NarrativeMapClaimRelation): NarrativeDisplayRelation {
202
+ const displayLabel = clean(relation.displayLabel)
203
+ const displayRationale = clean(relation.displayRationale)
204
+ if (displayLabel && canonical.inferred) throw new Error("Display label cannot replace inferred, non-canonical claim relation status.")
205
+ if (displayRationale && canonical.inferred) throw new Error("Display rationale cannot replace inferred, non-canonical claim relation rationale.")
206
+ if (displayRationale && !canonical.rationale?.trim()) throw new Error("Display rationale requires canonical claim relation rationale.")
207
+ return {
208
+ fromClaimId: relation.fromClaimId,
209
+ toClaimId: relation.toClaimId,
210
+ relation: relation.relation,
211
+ displayLabel,
212
+ displayRationale,
213
+ }
214
+ }
215
+
216
+ function clean(value: string | undefined): string | undefined {
217
+ const text = value?.trim()
218
+ return text || undefined
219
+ }
@@ -0,0 +1,246 @@
1
+ import type { DecksState } from "../decks-state"
2
+ import { recordWorkspaceAction } from "../workspace-state/actions"
3
+ import {
4
+ artifactNodeIdForRenderTarget,
5
+ normalizeWorkspacePath,
6
+ renderTargetId,
7
+ upsertRenderTarget,
8
+ } from "../workspace-state/render-targets"
9
+ import type { RenderTarget } from "../workspace-state/types"
10
+ import { computeNarrativeHash } from "./hash"
11
+ import { normalizeNarrativeState } from "./normalize"
12
+ import { reviewNarrativeState } from "./readiness"
13
+ import type { NarrativeApproval, NarrativeClaim, NarrativeEvidenceBinding, NarrativeStateV1 } from "./types"
14
+
15
+ export const DEFAULT_EXECUTIVE_BRIEF_PATH = "briefs/executive-brief.md"
16
+
17
+ export interface CompileExecutiveBriefOptions {
18
+ outputPath?: string
19
+ now?: string
20
+ }
21
+
22
+ export type CompileExecutiveBriefResult =
23
+ | {
24
+ ok: true
25
+ state: DecksState
26
+ outputPath: string
27
+ content: string
28
+ target: RenderTarget
29
+ narrativeHash: string
30
+ }
31
+ | {
32
+ ok: false
33
+ state: DecksState
34
+ reason: string
35
+ narrativeHash?: string
36
+ }
37
+
38
+ export function compileExecutiveBrief(state: DecksState, options: CompileExecutiveBriefOptions = {}): CompileExecutiveBriefResult {
39
+ const now = options.now ?? new Date().toISOString()
40
+ const reviewed = reviewNarrativeState(state, { now })
41
+ const narrative = reviewed.state.narrative ?? normalizeNarrativeState(reviewed.state)
42
+ const narrativeHash = reviewed.result.narrativeHash || computeNarrativeHash(narrative)
43
+ const allowedApproval = currentNarrativeApprovalOrOverride(narrative, narrativeHash)
44
+
45
+ if (!allowedApproval) {
46
+ return {
47
+ ok: false,
48
+ state: reviewed.state,
49
+ reason: "Executive brief rendering requires current narrative approval or an explicit render override.",
50
+ narrativeHash,
51
+ }
52
+ }
53
+
54
+ const outputPath = normalizeWorkspacePath(options.outputPath || DEFAULT_EXECUTIVE_BRIEF_PATH)
55
+ const content = renderExecutiveBriefMarkdown(narrative, narrativeHash, now, allowedApproval)
56
+ const claimIds = narrative.claims.map((claim) => claim.id).sort()
57
+ const evidenceBindingIds = narrative.evidenceBindings.map((binding) => binding.id).sort()
58
+ const target: RenderTarget = {
59
+ id: renderTargetId("executive_brief", outputPath),
60
+ type: "executive_brief",
61
+ outputPath,
62
+ sourceNodeIds: [narrative.id, ...claimIds, ...evidenceBindingIds],
63
+ artifactVersion: narrativeHash,
64
+ contractStatus: "valid",
65
+ data: {
66
+ narrativeHash,
67
+ generatedAt: now,
68
+ format: "markdown",
69
+ claimIds,
70
+ evidenceBindingIds,
71
+ approvalId: allowedApproval.id,
72
+ approvalScope: allowedApproval.scope,
73
+ },
74
+ }
75
+
76
+ const next: DecksState = { ...reviewed.state, narrative }
77
+ upsertRenderTarget(next, target)
78
+ recordWorkspaceAction(next, {
79
+ type: "artifact.rendered",
80
+ actor: "revela-brief",
81
+ inputs: {
82
+ type: "executive_brief",
83
+ narrativeId: narrative.id,
84
+ narrativeHash,
85
+ approvalId: allowedApproval.id,
86
+ },
87
+ outputs: {
88
+ outputPath,
89
+ targetId: target.id,
90
+ claimCount: claimIds.length,
91
+ evidenceBindingCount: evidenceBindingIds.length,
92
+ },
93
+ status: "success",
94
+ summary: "Rendered executive brief from approved narrative state.",
95
+ nodeIds: [target.id, artifactNodeIdForRenderTarget(target), narrative.id, ...claimIds],
96
+ timestamp: now,
97
+ })
98
+
99
+ return { ok: true, state: next, outputPath, content, target, narrativeHash }
100
+ }
101
+
102
+ function currentNarrativeApprovalOrOverride(narrative: NarrativeStateV1, narrativeHash: string): NarrativeApproval | undefined {
103
+ const approvals = [...(narrative.approvals ?? [])]
104
+ for (let index = approvals.length - 1; index >= 0; index -= 1) {
105
+ const approval = approvals[index]
106
+ if (approval.narrativeHash !== narrativeHash) continue
107
+ if (approval.scope === "narrative" && approval.approvedBy === "user") return approval
108
+ if (approval.scope === "render_override" || approval.approvedBy === "override") return approval
109
+ }
110
+ return undefined
111
+ }
112
+
113
+ function renderExecutiveBriefMarkdown(narrative: NarrativeStateV1, narrativeHash: string, generatedAt: string, approval: NarrativeApproval): string {
114
+ const evidenceByClaim = groupEvidenceByClaim(narrative.evidenceBindings)
115
+ const centralClaims = narrative.claims.filter((claim) => claim.importance === "central")
116
+ const supportingClaims = narrative.claims.filter((claim) => claim.importance !== "central")
117
+ const lines: string[] = []
118
+
119
+ lines.push("# Executive Brief")
120
+ lines.push("")
121
+ lines.push(`Generated: ${generatedAt}`)
122
+ lines.push(`Narrative ID: ${narrative.id}`)
123
+ lines.push(`Narrative hash: ${narrativeHash}`)
124
+ lines.push(`Approval: ${approval.id} (${approval.scope}, ${approval.approvedBy})`)
125
+ lines.push("")
126
+ lines.push("## Decision Context")
127
+ lines.push(`- Audience: ${fallback(narrative.audience.primary)}`)
128
+ lines.push(`- Belief before: ${fallback(narrative.audience.beliefBefore)}`)
129
+ lines.push(`- Belief after: ${fallback(narrative.audience.beliefAfter)}`)
130
+ lines.push(`- Decision/action: ${fallback(narrative.decision.action)}`)
131
+ if (narrative.decision.owner) lines.push(`- Owner: ${narrative.decision.owner}`)
132
+ if (narrative.decision.deadline) lines.push(`- Deadline: ${narrative.decision.deadline}`)
133
+ if (narrative.decision.consequenceOfNoDecision) lines.push(`- Consequence of no decision: ${narrative.decision.consequenceOfNoDecision}`)
134
+ lines.push("")
135
+ lines.push("## Thesis")
136
+ lines.push(narrative.thesis?.statement ? `${narrative.thesis.statement} (${narrative.thesis.confidence} confidence)` : "Not recorded.")
137
+ if (narrative.thesis?.caveat) lines.push(`Caveat: ${narrative.thesis.caveat}`)
138
+ lines.push("")
139
+
140
+ appendClaims(lines, "Central Claims", centralClaims, evidenceByClaim)
141
+ appendClaims(lines, "Supporting Claims", supportingClaims, evidenceByClaim)
142
+ appendObjections(lines, narrative)
143
+ appendRisks(lines, narrative)
144
+ appendResearchGaps(lines, narrative)
145
+ lines.push("## Provenance")
146
+ lines.push(`- Render target: executive_brief`)
147
+ lines.push(`- Source narrative: ${narrative.id}`)
148
+ lines.push(`- Narrative hash: ${narrativeHash}`)
149
+ lines.push(`- Approval id: ${approval.id}`)
150
+ lines.push("- This brief is compiled from canonical narrative state, not from a deck summary.")
151
+ lines.push("")
152
+
153
+ return lines.join("\n")
154
+ }
155
+
156
+ function appendClaims(lines: string[], title: string, claims: NarrativeClaim[], evidenceByClaim: Map<string, NarrativeEvidenceBinding[]>): void {
157
+ lines.push(`## ${title}`)
158
+ if (claims.length === 0) {
159
+ lines.push("Not recorded.")
160
+ lines.push("")
161
+ return
162
+ }
163
+
164
+ for (const claim of claims) {
165
+ lines.push(`### ${claim.text}`)
166
+ lines.push(`- Claim ID: ${claim.id}`)
167
+ lines.push(`- Kind: ${claim.kind}`)
168
+ lines.push(`- Evidence status: ${claim.evidenceStatus}`)
169
+ if (claim.supportedScope) lines.push(`- Supported scope: ${claim.supportedScope}`)
170
+ if (claim.unsupportedScope) lines.push(`- Unsupported scope: ${claim.unsupportedScope}`)
171
+ for (const caveat of claim.caveats ?? []) lines.push(`- Caveat: ${caveat}`)
172
+ const bindings = evidenceByClaim.get(claim.id) ?? []
173
+ if (bindings.length === 0) lines.push("- Evidence: none bound")
174
+ else {
175
+ lines.push("- Evidence:")
176
+ for (const binding of bindings) appendEvidence(lines, binding)
177
+ }
178
+ lines.push("")
179
+ }
180
+ }
181
+
182
+ function appendEvidence(lines: string[], binding: NarrativeEvidenceBinding): void {
183
+ lines.push(` - ${binding.id} (${binding.strength})`)
184
+ lines.push(` - Source: ${binding.source}`)
185
+ if (binding.findingsFile) lines.push(` - Findings file: ${binding.findingsFile}`)
186
+ if (binding.sourcePath) lines.push(` - Source path: ${binding.sourcePath}`)
187
+ if (binding.location) lines.push(` - Location: ${binding.location}`)
188
+ if (binding.url) lines.push(` - URL: ${binding.url}`)
189
+ if (binding.quote) lines.push(` - Quote: ${binding.quote}`)
190
+ if (binding.supportScope) lines.push(` - Support scope: ${binding.supportScope}`)
191
+ if (binding.unsupportedScope) lines.push(` - Unsupported scope: ${binding.unsupportedScope}`)
192
+ if (binding.caveat) lines.push(` - Caveat: ${binding.caveat}`)
193
+ }
194
+
195
+ function appendObjections(lines: string[], narrative: NarrativeStateV1): void {
196
+ lines.push("## Objections")
197
+ if (narrative.objections.length === 0) lines.push("Not recorded.")
198
+ for (const objection of narrative.objections) {
199
+ lines.push(`- ${objection.text}`)
200
+ lines.push(` - Objection ID: ${objection.id}`)
201
+ if (objection.claimId) lines.push(` - Challenges claim: ${objection.claimId}`)
202
+ lines.push(` - Priority: ${objection.priority}`)
203
+ if (objection.response) lines.push(` - Response: ${objection.response}`)
204
+ }
205
+ lines.push("")
206
+ }
207
+
208
+ function appendRisks(lines: string[], narrative: NarrativeStateV1): void {
209
+ lines.push("## Risks")
210
+ if (narrative.risks.length === 0) lines.push("Not recorded.")
211
+ for (const risk of narrative.risks) {
212
+ lines.push(`- ${risk.text}`)
213
+ lines.push(` - Risk ID: ${risk.id}`)
214
+ if (risk.claimId) lines.push(` - Constrains claim: ${risk.claimId}`)
215
+ lines.push(` - Severity: ${risk.severity}`)
216
+ if (risk.mitigation) lines.push(` - Mitigation: ${risk.mitigation}`)
217
+ }
218
+ lines.push("")
219
+ }
220
+
221
+ function appendResearchGaps(lines: string[], narrative: NarrativeStateV1): void {
222
+ lines.push("## Research Gaps")
223
+ const gaps = narrative.researchGaps ?? []
224
+ if (gaps.length === 0) lines.push("Not recorded.")
225
+ for (const gap of gaps) {
226
+ lines.push(`- ${gap.question}`)
227
+ lines.push(` - Gap ID: ${gap.id}`)
228
+ lines.push(` - Status: ${gap.status}`)
229
+ lines.push(` - Priority: ${gap.priority}`)
230
+ if (gap.targetId) lines.push(` - Target: ${gap.targetType}:${gap.targetId}`)
231
+ if (gap.findingsFile) lines.push(` - Findings file: ${gap.findingsFile}`)
232
+ if (gap.evidenceBindingIds?.length) lines.push(` - Evidence bindings: ${gap.evidenceBindingIds.join(", ")}`)
233
+ if (gap.notes) lines.push(` - Notes: ${gap.notes}`)
234
+ }
235
+ lines.push("")
236
+ }
237
+
238
+ function groupEvidenceByClaim(bindings: NarrativeEvidenceBinding[]): Map<string, NarrativeEvidenceBinding[]> {
239
+ const grouped = new Map<string, NarrativeEvidenceBinding[]>()
240
+ for (const binding of bindings) grouped.set(binding.claimId, [...(grouped.get(binding.claimId) ?? []), binding])
241
+ return grouped
242
+ }
243
+
244
+ function fallback(value: string | undefined): string {
245
+ return value?.trim() || "Not recorded"
246
+ }
@@ -9,6 +9,10 @@ export function stableClaimId(text: string): string {
9
9
  return `claim:${stableHash(text)}`
10
10
  }
11
11
 
12
+ export function stableClaimRelationId(fromClaimId: string, toClaimId: string, relation: string): string {
13
+ return `claim-relation:${stableHash(`${fromClaimId}:${toClaimId}:${relation}`)}`
14
+ }
15
+
12
16
  export function stableEvidenceId(claimId: string, seed: string): string {
13
17
  return `evidence:${claimId}:${stableHash(seed)}`
14
18
  }
@@ -21,6 +25,10 @@ export function stableRiskId(text: string): string {
21
25
  return `risk:${stableHash(text)}`
22
26
  }
23
27
 
28
+ export function stableResearchGapId(seed: string): string {
29
+ return `research-gap:${stableHash(seed)}`
30
+ }
31
+
24
32
  export function computeNarrativeHash(narrative: NarrativeStateV1): string {
25
33
  return stableHash(stableStringify({
26
34
  version: narrative.version,
@@ -29,6 +37,7 @@ export function computeNarrativeHash(narrative: NarrativeStateV1): string {
29
37
  decision: narrative.decision,
30
38
  thesis: narrative.thesis,
31
39
  claims: narrative.claims,
40
+ claimRelations: narrative.claimRelations ?? [],
32
41
  evidenceBindings: narrative.evidenceBindings,
33
42
  objections: narrative.objections,
34
43
  risks: narrative.risks,