@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,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
@@ -459,6 +459,7 @@ async function handleInspect(req: Request, session: EditSession): Promise<Respon
459
459
  }
460
460
 
461
461
  const snapshot = normalizeSnapshot(body?.snapshot ?? body)
462
+ const language = normalizeInspectLanguage(body?.language)
462
463
  const requestId = typeof body?.requestId === "string" && body.requestId.trim() ? body.requestId.trim() : randomBytes(10).toString("base64url")
463
464
  const version = readDeckVersion(session).version
464
465
  const staleReason = typeof body?.deckVersion === "string" && body.deckVersion !== version
@@ -480,6 +481,7 @@ async function handleInspect(req: Request, session: EditSession): Promise<Respon
480
481
  text: buildInspectionPrompt({
481
482
  requestId,
482
483
  file: session.file,
484
+ language,
483
485
  projection: staleReason
484
486
  ? { ...projection, stale: { stale: true, reason: staleReason } } as any
485
487
  : projection,
@@ -491,7 +493,7 @@ async function handleInspect(req: Request, session: EditSession): Promise<Respon
491
493
  failInspectRequest(requestId, message)
492
494
  })
493
495
 
494
- return jsonResponse({ ok: true, requestId, deckVersion: version, status: "pending", preprocess })
496
+ return jsonResponse({ ok: true, requestId, deckVersion: version, status: "pending", language, preprocess })
495
497
  } catch (error) {
496
498
  const message = error instanceof Error ? error.message : String(error)
497
499
  failInspectRequest(requestId, message)
@@ -510,6 +512,11 @@ function handleInspectResult(requestId: string | null, session: EditSession): Re
510
512
  return jsonResponse({ ok: true, requestId, status: request.status, deckVersion: request.deckVersion })
511
513
  }
512
514
 
515
+ function normalizeInspectLanguage(input: unknown): string {
516
+ const value = typeof input === "string" ? input.trim() : ""
517
+ return value || "Auto"
518
+ }
519
+
513
520
  function normalizeSnapshot(input: any): InspectionElementSnapshot {
514
521
  return {
515
522
  scope: input?.scope === "selection" || input?.scope === "slide" || input?.scope === "element" ? input.scope : undefined,
@@ -657,6 +664,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
657
664
  .comment-bubble.stale .comment-bubble-state { color: #a16207; }
658
665
  .comment-bubble.failed .comment-bubble-state { color: #b91c1c; }
659
666
  .inspect-actions { display: flex; flex-direction: column; gap: 8px; }
667
+ .inspect-options { display: flex; flex-direction: column; gap: 5px; }
668
+ .inspect-options label { color: #64748b; font-size: 11px; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; }
669
+ .inspect-select { width: 100%; padding: 10px 11px; border: 1px solid #d7e0ea; border-radius: 12px; background: #fff; color: #0f172a; font-weight: 700; }
660
670
  .inspect-cards { display: flex; flex-direction: column; gap: 12px; }
661
671
  .inspect-card { border: 1px solid #d7e0ea; border-radius: 16px; background: #fff; padding: 13px; box-shadow: 0 10px 24px rgba(15,23,42,.05); }
662
672
  .inspect-card-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 8px; }
@@ -681,7 +691,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
681
691
  <aside>
682
692
  <div>
683
693
  <h1><span class="wordmark">REVELA</span> Refine</h1>
684
- <p class="hint">Cmd/Ctrl-click slide elements once, then use Edit for fast changes or Inspect for Source/Purpose review.</p>
694
+ <p class="hint">Cmd/Ctrl-click slide elements once, then use Edit for fast changes or Inspect for Narrative Reading, Exploratory Reading, Source, and Purpose review.</p>
685
695
  </div>
686
696
  <div id="selectionSummary" class="selection-summary"><strong>Selection</strong><span>No references selected.</span><div id="selectionChips" class="selection-chips"></div></div>
687
697
  <div class="tabs" role="tablist" aria-label="Refine mode">
@@ -698,10 +708,11 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
698
708
  </div>
699
709
  <div id="inspectPanel" class="tab-panel">
700
710
  <div class="inspect-actions">
711
+ <div class="inspect-options"><label for="inspectLanguage">Display Language</label><select id="inspectLanguage" class="inspect-select"><option>Auto</option><option>English</option><option>简体中文</option><option>繁體中文</option><option>日本語</option><option>Deutsch</option><option>Français</option><option>Español</option><option>Português</option><option>Arabic</option></select></div>
701
712
  <button id="inspectButton" disabled>Inspect Selection</button>
702
713
  <div id="inspectStale"></div>
703
714
  </div>
704
- <div id="inspectCards" class="inspect-cards"><div class="inspect-empty">Select one or more deck elements, then inspect them for Source and Purpose. This does not edit the deck.</div></div>
715
+ <div id="inspectCards" class="inspect-cards"><div class="inspect-empty">Select one or more deck elements, then inspect them for Narrative Reading, Exploratory Reading, Source, and Purpose. This does not edit the deck.</div></div>
705
716
  </div>
706
717
  <div id="status" class="status"></div>
707
718
  </aside>
@@ -744,6 +755,8 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
744
755
  mode: defaultMode === 'inspect' ? 'inspect' : 'edit',
745
756
  inspecting: false,
746
757
  activeInspectRequestId: '',
758
+ inspectLanguage: 'Auto',
759
+ inspectFallback: null,
747
760
  };
748
761
  const els = {
749
762
  frame: null,
@@ -759,6 +772,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
759
772
  commentThread: null,
760
773
  send: null,
761
774
  inspectButton: null,
775
+ inspectLanguage: null,
762
776
  inspectCards: null,
763
777
  inspectStale: null,
764
778
  status: null,
@@ -792,7 +806,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
792
806
  els.inspectStale = document.getElementById('inspectStale');
793
807
  els.status = document.getElementById('status');
794
808
 
795
- if (!els.frame || !els.hitbox || !els.resizeHandle || !els.selectionSummary || !els.selectionChips || !els.editTab || !els.inspectTab || !els.editPanel || !els.inspectPanel || !els.comment || !els.commentThread || !els.send || !els.inspectButton || !els.inspectCards || !els.inspectStale || !els.status) {
809
+ els.inspectLanguage = document.getElementById('inspectLanguage');
810
+
811
+ if (!els.frame || !els.hitbox || !els.resizeHandle || !els.selectionSummary || !els.selectionChips || !els.editTab || !els.inspectTab || !els.editPanel || !els.inspectPanel || !els.comment || !els.commentThread || !els.send || !els.inspectButton || !els.inspectLanguage || !els.inspectCards || !els.inspectStale || !els.status) {
796
812
  throw new Error('Editor boot failed: required DOM nodes are missing.');
797
813
  }
798
814
 
@@ -840,6 +856,9 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
840
856
  els.resizeHandle.addEventListener('dblclick', resetEditorWidth);
841
857
  els.send.addEventListener('click', sendComment);
842
858
  els.inspectButton.addEventListener('click', inspectCurrentSelection);
859
+ els.inspectLanguage.addEventListener('change', () => {
860
+ state.inspectLanguage = els.inspectLanguage.value || 'Auto';
861
+ });
843
862
  els.editTab.addEventListener('click', () => setMode('edit'));
844
863
  els.inspectTab.addEventListener('click', () => setMode('inspect'));
845
864
  }
@@ -1075,7 +1094,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1075
1094
  renderReferenceOutlines();
1076
1095
  updateSendState();
1077
1096
  renderSelectionSummary();
1078
- resetInspectCards('References ready. Open Inspect and click Inspect Selection when you want Source/Purpose review.');
1097
+ resetInspectCards('References ready. Open Inspect and click Inspect Selection when you want Narrative Reading, Exploratory Reading, Source, and Purpose review.');
1079
1098
  setStatus('Inserted @' + label + '. ' + state.references.length + ' reference' + (state.references.length === 1 ? '' : 's') + ' will be sent.');
1080
1099
  }
1081
1100
 
@@ -1286,22 +1305,28 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1286
1305
  updateSendState();
1287
1306
  setMode('inspect');
1288
1307
  els.inspectStale.innerHTML = '';
1289
- els.inspectCards.innerHTML = '<div class="inspect-loading"><b>Preparing inspection...</b><br>Deterministic Source/Purpose appears first; generated cards update lazily.</div>';
1308
+ state.inspectFallback = null;
1309
+ els.inspectCards.innerHTML = '<div class="inspect-loading"><b>Reading selection...</b><br>Sending grounded selection context to OpenCode. Deterministic context is kept as fallback if generation fails.</div>';
1290
1310
  try {
1291
1311
  const res = await fetch('/api/inspect?token=' + encodeURIComponent(token), {
1292
1312
  method: 'POST',
1293
1313
  headers: { 'content-type': 'application/json' },
1294
- body: JSON.stringify({ snapshot, deckVersion: state.deckVersion }),
1314
+ body: JSON.stringify({ snapshot, deckVersion: state.deckVersion, language: state.inspectLanguage }),
1295
1315
  });
1296
1316
  const body = await res.json().catch(() => ({}));
1297
1317
  if (!res.ok || !body.ok) throw new Error(body.error || 'Inspection failed');
1298
1318
  state.deckVersion = body.deckVersion || state.deckVersion;
1299
1319
  state.activeInspectRequestId = body.requestId;
1300
- if (body.preprocess) renderInspectResult(body.preprocess, 'Preprocessed');
1301
- els.inspectCards.insertAdjacentHTML('beforeend', '<div class="inspect-loading"><b>Generating lazy inspection...</b><br>Waiting for structured result from OpenCode.</div>');
1320
+ state.inspectFallback = body.preprocess || null;
1321
+ els.inspectCards.innerHTML = '<div class="inspect-loading"><b>Reading selection...</b><br>Waiting for localized structured reading cards.</div>';
1302
1322
  await pollInspectResult(body.requestId);
1303
1323
  } catch (error) {
1304
- resetInspectCards(error && error.message ? error.message : String(error));
1324
+ if (state.inspectFallback) {
1325
+ renderInspectResult(state.inspectFallback, 'Deterministic fallback');
1326
+ els.inspectCards.insertAdjacentHTML('afterbegin', '<div class="inspect-warning">Generated inspection failed or timed out. Showing deterministic fallback context only.</div>');
1327
+ } else {
1328
+ resetInspectCards(error && error.message ? error.message : String(error));
1329
+ }
1305
1330
  } finally {
1306
1331
  state.inspecting = false;
1307
1332
  updateSendState();
@@ -1309,7 +1334,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1309
1334
  }
1310
1335
 
1311
1336
  async function pollInspectResult(requestId) {
1312
- for (;;) {
1337
+ for (let attempt = 0; attempt < 80; attempt++) {
1313
1338
  await delay(900);
1314
1339
  const res = await fetch('/api/inspect-result?token=' + encodeURIComponent(token) + '&requestId=' + encodeURIComponent(requestId));
1315
1340
  const body = await res.json().catch(() => ({}));
@@ -1321,6 +1346,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1321
1346
  }
1322
1347
  if (body.status === 'failed' || body.status === 'expired') throw new Error(body.error || 'Inspection failed');
1323
1348
  }
1349
+ throw new Error('Inspection timed out while waiting for OpenCode result');
1324
1350
  }
1325
1351
 
1326
1352
  function collectReferenceSnapshot() {
@@ -1351,6 +1377,8 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1351
1377
  else els.inspectStale.innerHTML = '';
1352
1378
  els.inspectCards.innerHTML = [
1353
1379
  '<div class="status">' + escapeHtml(phase || 'Inspection') + '</div>',
1380
+ result.cards.reading ? renderInspectCard('Narrative Reading', result.cards.reading.status, result.cards.reading.rationale, renderReading(result.cards.reading)) : '',
1381
+ result.cards.exploratory ? renderInspectCard('Exploratory Reading', result.cards.exploratory.status, result.cards.exploratory.rationale, renderExploratory(result.cards.exploratory)) : '',
1354
1382
  renderInspectCard('Purpose', result.cards.purpose.status, result.cards.purpose.rationale, renderPurpose(result.cards.purpose)),
1355
1383
  renderInspectCard('Source', result.cards.source.status, result.cards.source.rationale, renderSource(result.cards.source)),
1356
1384
  ].join('');
@@ -1364,6 +1392,49 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1364
1392
  return '<div class="inspect-item">' + field('Role', card.role) + field('Why it matters', card.whyItMatters) + '</div>';
1365
1393
  }
1366
1394
 
1395
+ function renderReading(card) {
1396
+ return '<div class="inspect-item">'
1397
+ + field('Claim ID', card.claimId)
1398
+ + field('Canonical claim ID', card.canonicalClaimId)
1399
+ + field('Claim', card.claimText)
1400
+ + field('Evidence status', card.evidenceStatus)
1401
+ + field('Evidence bindings', card.evidenceBindingIds && card.evidenceBindingIds.length ? card.evidenceBindingIds.join(', ') : '')
1402
+ + field('Supported scope', card.supportedScope)
1403
+ + field('Unsupported scope', card.unsupportedScope)
1404
+ + '</div>'
1405
+ + renderSectionList('Caveats', card.caveats)
1406
+ + renderSectionList('Objections', card.relatedObjections)
1407
+ + renderSectionList('Risks', card.relatedRisks)
1408
+ + renderArtifactCoverage(card.artifactCoverage);
1409
+ }
1410
+
1411
+ function renderArtifactCoverage(items) {
1412
+ if (!items || !items.length) return '';
1413
+ return '<div class="label">Artifact Coverage</div>' + items.map((item) => {
1414
+ const title = (item.type || 'artifact') + (item.outputPath ? ' · ' + item.outputPath : '');
1415
+ const status = (item.coverageStatus || 'unknown') + (item.containsClaim ? ' · contains claim' : ' · claim not rendered');
1416
+ return '<div class="inspect-item"><b>' + escapeHtml(title) + '</b>'
1417
+ + field('Coverage', status)
1418
+ + field('Stale', item.stale ? (item.staleReason || 'stale') : '')
1419
+ + field('Note', item.note)
1420
+ + renderSectionList('Locations', item.locations)
1421
+ + '</div>';
1422
+ }).join('');
1423
+ }
1424
+
1425
+ function renderExploratory(card) {
1426
+ return '<div class="inspect-item"><b>Non-official reading aid</b>'
1427
+ + field('Official artifact content', card.official === false ? 'No' : '')
1428
+ + field('Audience', card.audience)
1429
+ + field('Claim focus', card.claimFocus)
1430
+ + field('Audience reframe boundary', card.audienceReframe)
1431
+ + '</div>'
1432
+ + renderSectionList('Objection Prep', card.objectionPrompts)
1433
+ + renderSectionList('Appendix Leads', card.appendixLeads)
1434
+ + renderSectionList('Meeting Prep', card.meetingPrep)
1435
+ + renderSectionList('Boundaries', card.boundaries);
1436
+ }
1437
+
1367
1438
  function renderSource(card) {
1368
1439
  return renderSources(card.sources) + renderWarnings(card.warnings) + renderSectionList('Gaps', card.gaps) + renderSectionList('Caveats', card.caveats);
1369
1440
  }
@@ -1422,7 +1493,7 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1422
1493
  state.references = [];
1423
1494
  if (removeChips) els.comment.querySelectorAll('.ref-chip').forEach((chip) => chip.remove());
1424
1495
  renderSelectionSummary();
1425
- resetInspectCards('Select one or more deck elements, then inspect them for Source and Purpose. This does not edit the deck.');
1496
+ resetInspectCards('Select one or more deck elements, then inspect them for Narrative Reading, Exploratory Reading, Source, and Purpose. This does not edit the deck.');
1426
1497
  }
1427
1498
 
1428
1499
  function getCommentText() {
@@ -1525,10 +1596,17 @@ export function renderRefineShell(token: string, defaultMode: RefineMode = "edit
1525
1596
  }
1526
1597
 
1527
1598
  function findSlide(node) {
1528
- return node.closest('.slide, [slide-qa], .slide-canvas, .page');
1599
+ if (!node || !node.closest) return null;
1600
+ return node.closest('.slide[data-slide-index]')
1601
+ || node.closest('.slide')
1602
+ || node.closest('[slide-qa]')
1603
+ || node.closest('.slide-canvas')
1604
+ || node.closest('.page');
1529
1605
  }
1530
1606
 
1531
1607
  function getSlides(doc) {
1608
+ const canonicalSlides = Array.from(doc.querySelectorAll('.slide[data-slide-index]'));
1609
+ if (canonicalSlides.length) return canonicalSlides;
1532
1610
  const slides = Array.from(doc.querySelectorAll('.slide'));
1533
1611
  if (slides.length) return slides;
1534
1612
  const qaSlides = Array.from(doc.querySelectorAll('[slide-qa]'));
@@ -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)