@cyber-dash-tech/revela 0.10.0 → 0.12.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 (44) hide show
  1. package/README.md +54 -28
  2. package/README.zh-CN.md +54 -28
  3. package/lib/commands/designs.ts +2 -2
  4. package/lib/commands/domains.ts +2 -2
  5. package/lib/commands/enable.ts +19 -19
  6. package/lib/commands/help.ts +5 -3
  7. package/lib/commands/init.ts +30 -19
  8. package/lib/commands/inspect.ts +1 -1
  9. package/lib/commands/pdf.ts +33 -5
  10. package/lib/commands/pptx.ts +14 -9
  11. package/lib/commands/refine.ts +1 -1
  12. package/lib/commands/review.ts +115 -1
  13. package/lib/deck-html/contract.ts +252 -0
  14. package/lib/decks-state.ts +111 -28
  15. package/lib/document-materials/extract.ts +20 -0
  16. package/lib/edit/resolve-deck.ts +13 -2
  17. package/lib/inspect/open.ts +3 -1
  18. package/lib/narrative-state/hash.ts +52 -0
  19. package/lib/narrative-state/normalize.ts +307 -0
  20. package/lib/narrative-state/project-compat.ts +14 -0
  21. package/lib/narrative-state/readiness.ts +289 -0
  22. package/lib/narrative-state/render-plan.ts +207 -0
  23. package/lib/narrative-state/types.ts +139 -0
  24. package/lib/prompt-builder.ts +59 -26
  25. package/lib/qa/export-gate.ts +8 -1
  26. package/lib/refine/open.ts +3 -1
  27. package/lib/workspace-state/actions.ts +71 -0
  28. package/lib/workspace-state/compat.ts +10 -0
  29. package/lib/workspace-state/evidence-status.ts +267 -0
  30. package/lib/workspace-state/graph.ts +544 -0
  31. package/lib/workspace-state/render-targets.ts +182 -0
  32. package/lib/workspace-state/rendered-artifacts.ts +43 -0
  33. package/lib/workspace-state/repository.ts +43 -0
  34. package/lib/workspace-state/research-attachments.ts +130 -0
  35. package/lib/workspace-state/review-snapshots.ts +127 -0
  36. package/lib/workspace-state/types.ts +122 -0
  37. package/package.json +1 -1
  38. package/plugin.ts +53 -3
  39. package/skill/NARRATIVE_SKILL.md +64 -0
  40. package/tools/decks.ts +233 -6
  41. package/tools/pdf.ts +9 -1
  42. package/tools/pptx.ts +10 -0
  43. package/tools/research-save.ts +15 -0
  44. package/tools/workspace-scan.ts +29 -1
@@ -1,8 +1,26 @@
1
- import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "fs"
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs"
2
2
  import { createHash } from "crypto"
3
- import { basename, dirname, join, resolve } from "path"
4
-
5
- export const DECKS_STATE_FILE = "DECKS.json"
3
+ import { basename, join, resolve } from "path"
4
+ import {
5
+ hasWorkspaceState,
6
+ readOrCreateWorkspaceState,
7
+ readWorkspaceState,
8
+ workspaceStatePath,
9
+ writeWorkspaceState,
10
+ } from "./workspace-state/repository"
11
+ import { ensureActiveHtmlDeckRenderTarget } from "./workspace-state/render-targets"
12
+ import {
13
+ activeReviewTargetId,
14
+ appendReviewSnapshot,
15
+ createReviewSnapshot,
16
+ isReviewSnapshotCurrent,
17
+ latestReviewSnapshotForTarget,
18
+ } from "./workspace-state/review-snapshots"
19
+ import { WORKSPACE_STATE_FILE, type RenderTarget, type ReviewSnapshot, type WorkspaceAction } from "./workspace-state/types"
20
+ import { normalizeCanonicalNarrativeState, normalizeNarrativeState } from "./narrative-state/normalize"
21
+ import type { NarrativeStateV1 } from "./narrative-state/types"
22
+
23
+ export const DECKS_STATE_FILE = WORKSPACE_STATE_FILE
6
24
 
7
25
  export type DeckProductionStatus = "planning" | "blocked" | "ready" | "written"
8
26
  export type SlideProductionStatus = "planned" | "ready" | "written" | "qa_passed" | "qa_failed"
@@ -12,6 +30,7 @@ export type NarrativeRole = "context" | "tension" | "evidence" | "recommendation
12
30
  export interface DecksState {
13
31
  version: 1
14
32
  activeDeck?: string
33
+ narrative?: NarrativeStateV1
15
34
  workspace: {
16
35
  brief?: string
17
36
  sourceMaterials: SourceMaterial[]
@@ -23,6 +42,9 @@ export interface DecksState {
23
42
  openQuestions: string[]
24
43
  }
25
44
  decks: Record<string, DeckSpec>
45
+ actions: WorkspaceAction[]
46
+ renderTargets: RenderTarget[]
47
+ reviews: ReviewSnapshot[]
26
48
  }
27
49
 
28
50
  export interface SourceMaterial {
@@ -249,11 +271,11 @@ export interface ReviewDeckStateOptions {
249
271
  }
250
272
 
251
273
  export function decksStatePath(workspaceRoot: string): string {
252
- return join(workspaceRoot, DECKS_STATE_FILE)
274
+ return workspaceStatePath(workspaceRoot, DECKS_STATE_FILE)
253
275
  }
254
276
 
255
277
  export function hasDecksState(workspaceRoot: string): boolean {
256
- return existsSync(decksStatePath(workspaceRoot))
278
+ return hasWorkspaceState(workspaceRoot, DECKS_STATE_FILE)
257
279
  }
258
280
 
259
281
  export function createEmptyDecksState(): DecksState {
@@ -266,6 +288,9 @@ export function createEmptyDecksState(): DecksState {
266
288
  openQuestions: [],
267
289
  },
268
290
  decks: {},
291
+ actions: [],
292
+ renderTargets: [],
293
+ reviews: [],
269
294
  }
270
295
  }
271
296
 
@@ -289,6 +314,7 @@ export function normalizeWorkspaceDeckState(state: DecksState, workspaceRoot: st
289
314
  delete normalized.decks[existingKey]
290
315
  normalized.decks[slug] = deck
291
316
  normalized.activeDeck = slug
317
+ ensureActiveHtmlDeckRenderTarget(normalized)
292
318
  return normalized
293
319
  }
294
320
 
@@ -326,21 +352,15 @@ export function createDeckSpec(input: Partial<DeckSpec> & { slug: string }): Dec
326
352
  }
327
353
 
328
354
  export function readDecksState(workspaceRoot: string): DecksState {
329
- const parsed = JSON.parse(readFileSync(decksStatePath(workspaceRoot), "utf-8")) as DecksState
330
- return normalizeDecksState(parsed)
355
+ return readWorkspaceState(workspaceRoot, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksStateWithNarrative })
331
356
  }
332
357
 
333
358
  export function writeDecksState(workspaceRoot: string, state: DecksState): void {
334
- const filePath = decksStatePath(workspaceRoot)
335
- mkdirSync(dirname(filePath), { recursive: true })
336
- writeFileSync(filePath, JSON.stringify(normalizeDecksState(state), null, 2) + "\n", "utf-8")
359
+ writeWorkspaceState(workspaceRoot, state, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksStateWithNarrative })
337
360
  }
338
361
 
339
362
  export function readOrCreateDecksState(workspaceRoot: string): DecksState {
340
- if (hasDecksState(workspaceRoot)) return readDecksState(workspaceRoot)
341
- const state = createEmptyDecksState()
342
- writeDecksState(workspaceRoot, state)
343
- return state
363
+ return readOrCreateWorkspaceState(workspaceRoot, createEmptyDecksState, { fileName: DECKS_STATE_FILE, normalize: normalizeDecksStateWithNarrative })
344
364
  }
345
365
 
346
366
  export function upsertDeck(state: DecksState, input: Partial<DeckSpec> & { slug: string }): DecksState {
@@ -354,6 +374,7 @@ export function upsertDeck(state: DecksState, input: Partial<DeckSpec> & { slug:
354
374
  const next = createDeckSpec({ ...existing, ...input, slug })
355
375
  normalized.decks[slug] = next
356
376
  normalized.activeDeck = slug
377
+ ensureActiveHtmlDeckRenderTarget(normalized)
357
378
  return normalized
358
379
  }
359
380
 
@@ -370,6 +391,7 @@ export function upsertSlides(state: DecksState, slug: string, slides: SlideSpec[
370
391
  deck.slides = [...byIndex.values()].sort((a, b) => a.index - b.index)
371
392
  normalized.decks[key] = deck
372
393
  normalized.activeDeck = key
394
+ ensureActiveHtmlDeckRenderTarget(normalized)
373
395
  return normalized
374
396
  }
375
397
 
@@ -459,26 +481,29 @@ export function reviewDeckState(state: DecksState, slug?: string, options: Revie
459
481
  const blockers = issues.filter((issue) => issue.severity === "blocker").map((issue) => issue.message)
460
482
  const warnings = issues.filter((issue) => issue.severity === "warning").map((issue) => issue.message)
461
483
  const evidenceCandidates = issues.flatMap((issue) => issue.evidenceCandidates ?? [])
484
+ const reviewedAt = new Date().toISOString()
462
485
  deck.writeReadiness = {
463
486
  status: blockers.length === 0 ? "ready" : "blocked",
464
487
  blockers,
465
- lastReviewedAt: new Date().toISOString(),
488
+ lastReviewedAt: reviewedAt,
466
489
  }
467
490
  deck.status = blockers.length === 0 ? "ready" : "blocked"
468
491
  normalized.decks[deck.slug] = deck
469
492
  normalized.activeDeck = deck.slug
493
+ const result: DeckStateReadinessResult = {
494
+ ready: blockers.length === 0,
495
+ slug: deck.slug,
496
+ status: deck.writeReadiness.status,
497
+ blocker: blockers.join("; "),
498
+ blockers,
499
+ warnings,
500
+ issues,
501
+ evidenceCandidates,
502
+ }
503
+ appendReviewSnapshot(normalized, createReviewSnapshot(normalized, { slug: deck.slug, result, reviewedAt }))
470
504
  return {
471
505
  state: normalized,
472
- result: {
473
- ready: blockers.length === 0,
474
- slug: deck.slug,
475
- status: deck.writeReadiness.status,
476
- blocker: blockers.join("; "),
477
- blockers,
478
- warnings,
479
- issues,
480
- evidenceCandidates,
481
- },
506
+ result,
482
507
  }
483
508
  }
484
509
 
@@ -542,6 +567,38 @@ export function evaluateDeckStateWriteReadiness(state: DecksState, filePath: str
542
567
  suggestedAction: "Resolve the stored writeReadiness blockers and rerun /revela review.",
543
568
  })
544
569
  }
570
+ if (normalized.reviews.length > 0) {
571
+ const targetId = activeReviewTargetId(normalized)
572
+ const snapshot = latestReviewSnapshotForTarget(normalized, targetId)
573
+ if (!snapshot) {
574
+ const message = "No review snapshot exists for the active HTML render target"
575
+ blockers.unshift(message)
576
+ issues.unshift({
577
+ type: "missing_slide_spec",
578
+ severity: "blocker",
579
+ message,
580
+ suggestedAction: "Run /revela review so readiness is recorded against the current active render target.",
581
+ })
582
+ } else if (!isReviewSnapshotCurrent(normalized, snapshot, deck.slug)) {
583
+ const message = "Latest review snapshot is stale for the current deck, sources, evidence, narrative state, or render target"
584
+ blockers.unshift(message)
585
+ issues.unshift({
586
+ type: "missing_slide_spec",
587
+ severity: "blocker",
588
+ message,
589
+ suggestedAction: "Run /revela review again after the latest state changes before writing deck HTML.",
590
+ })
591
+ } else if (snapshot.status !== "ready") {
592
+ const message = `Latest review snapshot is ${snapshot.status}, not ready`
593
+ blockers.unshift(message)
594
+ issues.unshift({
595
+ type: "missing_slide_spec",
596
+ severity: "blocker",
597
+ message,
598
+ suggestedAction: "Resolve review blockers and rerun /revela review before writing deck HTML.",
599
+ })
600
+ }
601
+ }
545
602
 
546
603
  return {
547
604
  ready: blockers.length === 0,
@@ -579,11 +636,12 @@ export function buildDecksStatePromptLayer(workspaceRoot: string, maxChars = 140
579
636
  activeDeck: activeKey,
580
637
  workspace: compactWorkspaceForPrompt(state.workspace),
581
638
  deck: active ? compactDeckForPrompt(active) : undefined,
639
+ renderTargets: state.renderTargets,
640
+ reviews: compactReviewsForPrompt(state.reviews),
582
641
  }
583
642
  let text = JSON.stringify(compact, null, 2)
584
643
  if (text.length > maxChars) text = text.slice(0, maxChars).trimEnd() + "\n[DECKS.json state truncated for prompt size.]"
585
- return `---\n\n# Revela Workspace State From ${DECKS_STATE_FILE}\n\n\`\`\`json\n${text}\n\`\`\`\n\nRules for this state layer:\n- Treat ${DECKS_STATE_FILE} as the source of truth for the single current deck's specs, slide plan, and write readiness.\n- The decks map is compatibility storage; operate only on the current workspace deck.\n- Do not edit ${DECKS_STATE_FILE} directly; use the revela-decks tool.\n- Before writing decks/*.html, the current deck must have writeReadiness.status=ready and a complete slide spec, and its outputPath must match the target file.`
586
- return `---\n\n# Revela Workspace State From ${DECKS_STATE_FILE}\n\n\`\`\`json\n${text}\n\`\`\`\n\nRules for this state layer:\n- Treat ${DECKS_STATE_FILE} as the source of truth for the single current deck's specs, slide plan, evidence, and write readiness.\n- The decks map is compatibility storage; operate only on the current workspace deck.\n- ${DECKS_STATE_FILE} deck slides use 1-based \`slides[].index\` values. Render every HTML \`<section class="slide">\` with a matching 1-based \`data-slide-index\` attribute, and do not use 0-based \`data-index\` as slide identity.\n- Do not edit ${DECKS_STATE_FILE} directly; use the revela-decks tool.\n- Before writing decks/*.html, the current deck must have writeReadiness.status=ready and a complete slide spec, and its outputPath must match the target file.`
644
+ return `---\n\n# Revela Workspace State From ${DECKS_STATE_FILE}\n\n\`\`\`json\n${text}\n\`\`\`\n\nRules for this state layer:\n- Treat ${DECKS_STATE_FILE} as the source of truth for the single current deck's specs, slide plan, evidence, render targets, and write readiness.\n- The decks map is compatibility storage; operate only on the current workspace deck.\n- ${DECKS_STATE_FILE} deck slides use 1-based \`slides[].index\` values. Render every HTML \`<section class="slide">\` with a matching 1-based \`data-slide-index\` attribute, and do not use 0-based \`data-index\` as slide identity.\n- The active HTML deck is represented as a \`renderTarget\` of type \`html_deck\`; PDF/PPTX exports should be recorded as derived render targets, not as separate deck specs.\n- \`writeReadiness\` is a compatibility projection. When review snapshots exist, deck HTML writes require a current non-stale ready review snapshot for the active HTML render target.\n- Do not edit ${DECKS_STATE_FILE} directly; use the revela-decks tool.\n- Before writing decks/*.html, the current deck must have writeReadiness.status=ready and a complete slide spec, and its outputPath must match the target file.`
587
645
  }
588
646
 
589
647
  function compactWorkspaceForPrompt(workspace: DecksState["workspace"]): DecksState["workspace"] {
@@ -616,6 +674,20 @@ function compactDeckForPrompt(deck: DeckSpec): DeckSpec {
616
674
  }
617
675
  }
618
676
 
677
+ function compactReviewsForPrompt(reviews: ReviewSnapshot[]): ReviewSnapshot[] {
678
+ return reviews.slice(-5).map((review) => ({
679
+ id: review.id,
680
+ targetId: review.targetId,
681
+ inputHash: review.inputHash,
682
+ status: review.status,
683
+ blockers: review.blockers.slice(0, 5),
684
+ warnings: review.warnings.slice(0, 5),
685
+ issues: review.issues.slice(0, 10),
686
+ evidenceCandidates: review.evidenceCandidates?.slice(0, 10),
687
+ reviewedAt: review.reviewedAt,
688
+ }))
689
+ }
690
+
619
691
  function compactNarrativeBriefForPrompt(brief: NarrativeBrief | undefined): NarrativeBrief | undefined {
620
692
  if (!brief) return undefined
621
693
  return {
@@ -648,6 +720,7 @@ function normalizeDecksState(input: DecksState): DecksState {
648
720
  const state: DecksState = {
649
721
  version: 1,
650
722
  activeDeck: input.activeDeck ? normalizeSlug(input.activeDeck) : undefined,
723
+ narrative: normalizeCanonicalNarrativeState(input.narrative, input.activeDeck || "workspace"),
651
724
  workspace: {
652
725
  brief: input.workspace?.brief,
653
726
  sourceMaterials: input.workspace?.sourceMaterials ?? [],
@@ -659,6 +732,9 @@ function normalizeDecksState(input: DecksState): DecksState {
659
732
  openQuestions: input.workspace?.openQuestions ?? [],
660
733
  },
661
734
  decks: {},
735
+ actions: input.actions ?? [],
736
+ renderTargets: input.renderTargets ?? [],
737
+ reviews: input.reviews ?? [],
662
738
  }
663
739
  for (const [slug, deck] of Object.entries(input.decks ?? {})) {
664
740
  const normalizedSlug = normalizeSlug(deck.slug || slug)
@@ -669,6 +745,13 @@ function normalizeDecksState(input: DecksState): DecksState {
669
745
  const keys = Object.keys(state.decks)
670
746
  if (keys.length === 1) state.activeDeck = keys[0]
671
747
  }
748
+ ensureActiveHtmlDeckRenderTarget(state)
749
+ return state
750
+ }
751
+
752
+ function normalizeDecksStateWithNarrative(input: DecksState): DecksState {
753
+ const state = normalizeDecksState(input)
754
+ if (!state.narrative && currentDeckKey(state)) state.narrative = normalizeNarrativeState(state)
672
755
  return state
673
756
  }
674
757
 
@@ -10,6 +10,7 @@ import { extractPptx } from "../read-hooks/extractors/pptx"
10
10
  import { extractXlsx } from "../read-hooks/extractors/xlsx"
11
11
  import { hasDecksState, readDecksState, writeDecksState } from "../decks-state"
12
12
  import { computeSourceFingerprint, sourceMaterialMetadata, upsertSourceMaterial } from "../source-materials"
13
+ import { recordWorkspaceAction } from "../workspace-state/actions"
13
14
 
14
15
  export type DocumentMaterial = {
15
16
  path: string
@@ -180,6 +181,25 @@ function updateDecksSourceMaterialIndex(
180
181
  : undefined,
181
182
  lastExtracted: extracted ? (result.cache_status === "hit" ? existing?.lastExtracted ?? now : now) : undefined,
182
183
  }, extracted ? "extracted" : "discovered")
184
+ recordWorkspaceAction(state, {
185
+ type: "source.extracted",
186
+ actor: "revela-extract-document-materials",
187
+ inputs: { file: base.path, type: base.type, fingerprint: base.fingerprint },
188
+ outputs: {
189
+ status: result.status,
190
+ cacheStatus: result.cache_status,
191
+ source: result.source,
192
+ manifestPath: result.manifest_path,
193
+ textPath: result.text_path,
194
+ cacheDir: result.cache_dir,
195
+ reason: result.reason,
196
+ imageCount: result.images?.length ?? 0,
197
+ tableCount: result.tables?.length ?? 0,
198
+ },
199
+ status: result.status === "failed" ? "failed" : result.status === "skipped" ? "skipped" : "success",
200
+ summary: extracted ? `Extracted reusable materials from ${base.path}.` : `Registered ${base.path} without reusable extraction output.`,
201
+ nodeIds: [`source:${base.path}`],
202
+ })
183
203
  writeDecksState(workspaceDir, state)
184
204
  }
185
205
 
@@ -1,12 +1,13 @@
1
1
  import { existsSync, readdirSync } from "fs"
2
2
  import { relative, resolve, sep } from "path"
3
- import { DECKS_STATE_FILE, isDeckHtmlPath, workspaceDeckSlug } from "../decks-state"
3
+ import { DECKS_STATE_FILE, hasDecksState, isDeckHtmlPath, readDecksState, workspaceDeckSlug } from "../decks-state"
4
+ import { resolveActiveHtmlDeckPath } from "../workspace-state/render-targets"
4
5
 
5
6
  export interface EditableDeck {
6
7
  slug: string
7
8
  file: string
8
9
  absoluteFile: string
9
- source: "decks-state" | "fallback" | "file-path"
10
+ source: "render-target" | "decks-state" | "fallback" | "file-path"
10
11
  }
11
12
 
12
13
  export function resolveEditableDeck(workspaceRoot: string, input = ""): EditableDeck {
@@ -14,6 +15,16 @@ export function resolveEditableDeck(workspaceRoot: string, input = ""): Editable
14
15
  throw new Error("/revela edit no longer accepts a target. It opens the only HTML deck in decks/.")
15
16
  }
16
17
 
18
+ if (hasDecksState(workspaceRoot)) {
19
+ const state = readDecksState(workspaceRoot)
20
+ const deckPath = resolveActiveHtmlDeckPath(state)
21
+ const source = state.renderTargets.some((target) => target.type === "html_deck" && target.outputPath === deckPath) ? "render-target" : "decks-state"
22
+ if (deckPath && isDeckHtmlPath(deckPath)) {
23
+ const absoluteFile = resolve(workspaceRoot, deckPath)
24
+ if (existsSync(absoluteFile)) return resolveDeckFile(workspaceRoot, workspaceDeckSlug(workspaceRoot), deckPath, source)
25
+ }
26
+ }
27
+
17
28
  const htmlFiles = listDeckHtmlFiles(workspaceRoot)
18
29
  if (htmlFiles.length === 0) {
19
30
  throw new Error("No deck HTML found in decks/. Generate a deck first.")
@@ -2,6 +2,7 @@ import { existsSync } from "fs"
2
2
  import { ACTIVE_PROMPT_FILE } from "../config"
3
3
  import { ctx } from "../ctx"
4
4
  import { seedBuiltinDesigns } from "../design/designs"
5
+ import { assertDeckHtmlContractValid } from "../deck-html/contract"
5
6
  import { seedBuiltinDomains } from "../domain/domains"
6
7
  import { ensureEditableDeckState } from "../edit/deck-state"
7
8
  import { openUrl } from "../edit/open"
@@ -30,6 +31,7 @@ export interface OpenInspectDeckOptions {
30
31
  export function openInspectDeck(target: string, options: OpenInspectDeckOptions): OpenInspectDeckResult {
31
32
  const deck = resolveEditableDeck(options.workspaceRoot, target)
32
33
  const preflight = ensureEditableDeckState(options.workspaceRoot, deck)
34
+ assertDeckHtmlContractValid(options.workspaceRoot, deck.absoluteFile)
33
35
 
34
36
  ctx.enabled = true
35
37
  if (!existsSync(ACTIVE_PROMPT_FILE)) {
@@ -52,7 +54,7 @@ export function openInspectDeck(target: string, options: OpenInspectDeckOptions)
52
54
  return {
53
55
  deck,
54
56
  url,
55
- source: deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path",
57
+ source: deck.source === "render-target" ? "render target" : deck.source === "decks-state" ? "DECKS.json" : deck.source === "file-path" ? "file path" : "fallback path",
56
58
  stateNote: preflight.changed ? "Deck state was prepared in DECKS.json for inspection." : "Deck state already points to this inspection target.",
57
59
  preflightChanged: preflight.changed,
58
60
  reusedSession: session.reused,
@@ -0,0 +1,52 @@
1
+ import { createHash } from "crypto"
2
+ import type { NarrativeStateV1 } from "./types"
3
+
4
+ export function stableNarrativeId(seed: string): string {
5
+ return `narrative:${stableHash(seed || "workspace")}`
6
+ }
7
+
8
+ export function stableClaimId(text: string): string {
9
+ return `claim:${stableHash(text)}`
10
+ }
11
+
12
+ export function stableEvidenceId(claimId: string, seed: string): string {
13
+ return `evidence:${claimId}:${stableHash(seed)}`
14
+ }
15
+
16
+ export function stableObjectionId(text: string): string {
17
+ return `objection:${stableHash(text)}`
18
+ }
19
+
20
+ export function stableRiskId(text: string): string {
21
+ return `risk:${stableHash(text)}`
22
+ }
23
+
24
+ export function computeNarrativeHash(narrative: NarrativeStateV1): string {
25
+ return stableHash(stableStringify({
26
+ version: narrative.version,
27
+ id: narrative.id,
28
+ audience: narrative.audience,
29
+ decision: narrative.decision,
30
+ thesis: narrative.thesis,
31
+ claims: narrative.claims,
32
+ evidenceBindings: narrative.evidenceBindings,
33
+ objections: narrative.objections,
34
+ risks: narrative.risks,
35
+ }))
36
+ }
37
+
38
+ export function stableHash(input: unknown): string {
39
+ const text = typeof input === "string" ? input : stableStringify(input)
40
+ return createHash("sha1").update(text).digest("hex").slice(0, 12)
41
+ }
42
+
43
+ function stableStringify(value: unknown): string {
44
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`
45
+ if (value && typeof value === "object") {
46
+ const entries = Object.entries(value as Record<string, unknown>)
47
+ .filter(([, item]) => item !== undefined)
48
+ .sort(([a], [b]) => a.localeCompare(b))
49
+ return `{${entries.map(([key, item]) => `${JSON.stringify(key)}:${stableStringify(item)}`).join(",")}}`
50
+ }
51
+ return JSON.stringify(value)
52
+ }