@cyber-dash-tech/revela 0.16.3 → 0.17.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 (49) hide show
  1. package/README.md +7 -5
  2. package/README.zh-CN.md +7 -5
  3. package/lib/commands/brief.ts +9 -0
  4. package/lib/commands/help.ts +5 -2
  5. package/lib/commands/init.ts +42 -27
  6. package/lib/commands/narrative.ts +26 -2
  7. package/lib/commands/research.ts +36 -20
  8. package/lib/commands/review.ts +21 -18
  9. package/lib/ctx.ts +1 -1
  10. package/lib/decks-state.ts +38 -4
  11. package/lib/edit/prompt.ts +1 -1
  12. package/lib/hook-notifications.ts +53 -0
  13. package/lib/narrative-state/render-plan.ts +114 -27
  14. package/lib/narrative-state/research-binding-eval.ts +260 -0
  15. package/lib/narrative-state/research-gaps.ts +2 -88
  16. package/lib/narrative-vault/authoring-contract.ts +127 -0
  17. package/lib/narrative-vault/authoring-guard.ts +122 -0
  18. package/lib/narrative-vault/auto-compile.ts +134 -0
  19. package/lib/narrative-vault/bootstrap.ts +63 -0
  20. package/lib/narrative-vault/cache.ts +14 -0
  21. package/lib/narrative-vault/compile-mirror.ts +45 -0
  22. package/lib/narrative-vault/compile.ts +350 -0
  23. package/lib/narrative-vault/constants.ts +6 -0
  24. package/lib/narrative-vault/diagnostic-report.ts +117 -0
  25. package/lib/narrative-vault/export.ts +71 -0
  26. package/lib/narrative-vault/frontmatter.ts +41 -0
  27. package/lib/narrative-vault/hook-targets.ts +40 -0
  28. package/lib/narrative-vault/index.ts +18 -0
  29. package/lib/narrative-vault/inventory.ts +392 -0
  30. package/lib/narrative-vault/markdown-qa.ts +237 -0
  31. package/lib/narrative-vault/markdown.ts +34 -0
  32. package/lib/narrative-vault/migration.ts +52 -0
  33. package/lib/narrative-vault/mutate.ts +361 -0
  34. package/lib/narrative-vault/paths.ts +19 -0
  35. package/lib/narrative-vault/read.ts +52 -0
  36. package/lib/narrative-vault/relations.ts +32 -0
  37. package/lib/narrative-vault/source-loader.ts +19 -0
  38. package/lib/narrative-vault/timestamp.ts +32 -0
  39. package/lib/narrative-vault/types.ts +44 -0
  40. package/lib/refine/server.ts +472 -5
  41. package/lib/refine/visual-targets.ts +295 -0
  42. package/lib/source-materials.ts +98 -0
  43. package/lib/tool-result.ts +34 -0
  44. package/package.json +2 -2
  45. package/plugin.ts +60 -22
  46. package/skill/NARRATIVE_SKILL.md +25 -10
  47. package/tools/decks.ts +363 -67
  48. package/tools/research-save.ts +3 -0
  49. package/tools/workspace-scan.ts +1 -0
package/tools/decks.ts CHANGED
@@ -11,13 +11,14 @@ import {
11
11
  writeDecksState,
12
12
  workspaceDeckSlug,
13
13
  type DeckSpec,
14
+ type DecksState,
14
15
  type NarrativeBrief,
15
16
  type RequiredInputs,
16
17
  type ResearchAxis,
17
18
  type SourceMaterial,
18
19
  type SlideSpec,
19
20
  } from "../lib/decks-state"
20
- import { upsertSourceMaterial } from "../lib/source-materials"
21
+ import { classifySourceMaterialIngest, upsertSourceMaterial } from "../lib/source-materials"
21
22
  import { recordWorkspaceAction } from "../lib/workspace-state/actions"
22
23
  import { applyEvidenceBindings } from "../lib/workspace-state/evidence-status"
23
24
  import { attachResearchFindings } from "../lib/workspace-state/research-attachments"
@@ -31,33 +32,81 @@ import {
31
32
  import { compileDeckPlanFromNarrative } from "../lib/narrative-state/render-plan"
32
33
  import { backfillSlideClaimRefsFromCoverage } from "../lib/narrative-state/coverage"
33
34
  import { closeResearchGapInState, deriveResearchGapsFromReadiness, deriveResearchTargets, updateResearchGapInState, upsertResearchGapsInState } from "../lib/narrative-state/research-gaps"
34
- import { normalizeCanonicalNarrativeState, normalizeNarrativeState } from "../lib/narrative-state/normalize"
35
- import { narrativeToBrief } from "../lib/narrative-state/project-compat"
36
- import type { NarrativeStateV1 } from "../lib/narrative-state/types"
35
+ import { evaluateResearchFindingsBinding } from "../lib/narrative-state/research-binding-eval"
36
+ import { stableEvidenceId } from "../lib/narrative-state/hash"
37
+ import { normalizeNarrativeState } from "../lib/narrative-state/normalize"
38
+ import { buildNarrativeVaultInventory, compileNarrativeVault, exportNarrativeStateToVault, formatVaultDiagnosticReport, getNarrativeVaultMigrationHint, hasNarrativeVault, initNarrativeVault, narrativeVaultAuthoringContract, narrativeVaultTimestampMs, removeVaultRelation, runNarrativeMarkdownQa, updateVaultCoreNodes, updateVaultResearchGapNode, upsertVaultClaimNode, upsertVaultEvidenceNode, upsertVaultObjectionNode, upsertVaultRelation, upsertVaultRiskNode, VAULT_MIGRATION_PRESERVED_IN_DECKS_JSON } from "../lib/narrative-vault"
39
+ import { compileCacheMirrorNarrativeVault } from "../lib/narrative-vault/compile-mirror"
37
40
 
38
- function mergeNarrativeInput(current: NarrativeStateV1, input: Partial<NarrativeStateV1>): Partial<NarrativeStateV1> {
41
+ function missingBindableEvidenceFields(input: Record<string, unknown>): string[] {
42
+ const missing: string[] = []
43
+ for (const key of ["id", "claimId", "source", "quote", "supportScope", "unsupportedScope", "caveat", "strength"] as const) {
44
+ if (!String(input[key] ?? "").trim()) missing.push(key)
45
+ }
46
+ if (!String(input.sourcePath ?? "").trim() && !String(input.url ?? "").trim() && !String(input.findingsFile ?? "").trim()) missing.push("sourcePath|url|findingsFile")
47
+ return missing
48
+ }
49
+
50
+ function exactResearchGapForBinding(state: DecksState, findingsFile: string, claimId: string) {
51
+ const gaps = state.narrative?.researchGaps ?? []
52
+ const exact = gaps.filter((gap) => gap.targetType === "claim" && gap.targetId === claimId && gap.findingsFile === findingsFile)
53
+ if (exact.length === 1) return exact[0]
54
+ if (exact.length > 1) return undefined
55
+ const byClaim = gaps.filter((gap) => gap.targetType === "claim" && gap.targetId === claimId && !gap.findingsFile)
56
+ return byClaim.length === 1 ? byClaim[0] : undefined
57
+ }
58
+
59
+ function forbiddenVaultCompatibilityAction(action: string, replacement: string): string {
60
+ return `${action} is a JSON-era compatibility action and is blocked in vault workspaces. Use ${replacement}, or patch the existing Markdown node only for a small read-before-edit repair.`
61
+ }
62
+
63
+ function researchGapHelperRecovery(action: "updateVaultResearchGap" | "upsertVaultResearchGap", missingFields?: string[]) {
64
+ const missing = missingFields?.length ? ` Missing fields: ${missingFields.join(", ")}.` : ""
39
65
  return {
40
- ...current,
41
- ...input,
42
- id: current.id,
43
- version: 1,
44
- audience: {
45
- ...current.audience,
46
- ...(input.audience ?? {}),
66
+ nextActions: [
67
+ "If the research gap already exists, read the matching revela-narrative/research-gaps/*.md file and patch only the lifecycle fields.",
68
+ "If this is a new research gap, provide id, targetType, targetId, question, status, and priority, or create the Markdown node directly after checking narrativeInventory with a ## Relations depends_on wikilink.",
69
+ "Rerun revela-decks markdownQa and compileNarrativeVault after the repair.",
70
+ ],
71
+ examples: {
72
+ tool: {
73
+ action,
74
+ researchGaps: [{ id: "gap-market-size", targetType: "claim", targetId: "claim-market-context", question: "What source supports the market-size claim?", status: "open", priority: "high" }],
75
+ },
76
+ markdown: "---\ntype: research-gap\nid: gap-market-size\ntargetType: claim\ntargetId: claim-market-context\nquestion: What source supports the market-size claim?\nstatus: open\npriority: high\n---\nWhat source supports the market-size claim?\n\n## Relations\n\n- depends_on: [[claim-market-context]]\n",
47
77
  },
48
- decision: {
49
- ...current.decision,
50
- ...(input.decision ?? {}),
78
+ message: `Research gap helper could not complete.${missing}`,
79
+ }
80
+ }
81
+
82
+ function relationHelperRecovery(action: "upsertVaultRelation" | "removeVaultRelation", missingFields?: string[]) {
83
+ const missing = missingFields?.length ? ` Missing fields: ${missingFields.join(", ")}.` : ""
84
+ return {
85
+ message: `Relation registry helper is disabled in Markdown vault workspaces.${missing}`,
86
+ nextActions: [
87
+ "Run narrativeInventory before adding or removing relation edges; reuse existing node ids exactly.",
88
+ "Patch the source node's ## Relations section with a plain wikilink relation line; do not hand-write relation ids.",
89
+ "Rerun markdownQa and compileNarrativeVault so compiler-generated relation ids and diagnostics refresh.",
90
+ ],
91
+ examples: {
92
+ tool: { action, note: "Deprecated compatibility action; edit Markdown instead." },
93
+ markdown: "## Relations\n\n- supports: [[claim-execution-readiness]] - Pilot recommendation is supported by execution framing.\n",
51
94
  },
52
- thesis: input.thesis ? { ...current.thesis, ...input.thesis } as NarrativeStateV1["thesis"] : current.thesis,
53
- claims: input.claims ?? current.claims,
54
- claimRelations: input.claimRelations ?? current.claimRelations,
55
- evidenceBindings: input.evidenceBindings ?? current.evidenceBindings,
56
- objections: input.objections ?? current.objections,
57
- risks: input.risks ?? current.risks,
58
- researchGaps: input.researchGaps ?? current.researchGaps,
59
- approvals: current.approvals,
60
- updatedAt: new Date().toISOString(),
95
+ }
96
+ }
97
+
98
+ function strictVaultMarkdownQaGate(workspaceRoot: string, strictness: "readiness" | "render", action: string) {
99
+ if (!hasNarrativeVault(workspaceRoot)) return undefined
100
+ const markdownQa = runNarrativeMarkdownQa(workspaceRoot, { scope: "full", strictness })
101
+ if (markdownQa.ok) return undefined
102
+ return {
103
+ ok: false,
104
+ skipped: true,
105
+ action,
106
+ reason: `Markdown QA ${strictness} blockers must be repaired before ${action}.`,
107
+ markdownQa,
108
+ narrativeInventory: buildNarrativeVaultInventory(workspaceRoot),
109
+ authoringContract: narrativeVaultAuthoringContract(),
61
110
  }
62
111
  }
63
112
 
@@ -68,7 +117,7 @@ export default tool({
68
117
  "It stores workspace narrative state, active deck specs, per-slide content/layout/components, and computes narrative or deck readiness.",
69
118
  args: {
70
119
  action: tool.schema
71
- .enum(["read", "init", "upsertDeck", "upsertSlides", "upsertNarrative", "compileDeckPlan", "confirmDeckPlan", "backfillClaimRefs", "review", "reviewNarrative", "approveNarrative", "deriveResearchGaps", "deriveResearchTargets", "upsertResearchGaps", "updateResearchGap", "closeResearchGap", "applyEvidenceCandidates", "attachResearchFindings", "remember"])
120
+ .enum(["read", "init", "initNarrativeVault", "narrativeInventory", "vaultInventory", "markdownQa", "upsertDeck", "upsertSlides", "upsertNarrative", "compileNarrativeVault", "exportNarrativeVault", "upsertVaultEvidence", "bindResearchFindings", "updateVaultResearchGap", "upsertVaultResearchGap", "upsertVaultClaim", "upsertVaultObjection", "upsertVaultRisk", "upsertVaultRelation", "removeVaultRelation", "updateVaultCoreNarrative", "compileDeckPlan", "confirmDeckPlan", "backfillClaimRefs", "review", "reviewNarrative", "approveNarrative", "deriveResearchGaps", "deriveResearchTargets", "evaluateResearchFindings", "upsertResearchGaps", "updateResearchGap", "closeResearchGap", "applyEvidenceCandidates", "attachResearchFindings", "remember"])
72
121
  .describe("Action to perform on DECKS.json."),
73
122
  summary: tool.schema.boolean().optional().describe("For read: return a compact summary instead of full state."),
74
123
  goal: tool.schema.string().optional().describe("For upsertDeck: deck goal."),
@@ -127,7 +176,7 @@ export default tool({
127
176
  })).optional().describe("Canonical claim-to-claim narrative progression relationships. These affect narrative approval hash."),
128
177
  evidenceBindings: tool.schema.array(tool.schema.object({
129
178
  id: tool.schema.string().optional(),
130
- claimId: tool.schema.string().describe("Canonical claim id this evidence supports."),
179
+ claimId: tool.schema.string().describe("Compatibility fallback claim id this evidence supports; helpers also write a ## Relations supports wikilink."),
131
180
  source: tool.schema.string().describe("Source file, URL, research finding, or material name."),
132
181
  sourcePath: tool.schema.string().optional(),
133
182
  findingsFile: tool.schema.string().optional(),
@@ -165,7 +214,7 @@ export default tool({
165
214
  createdFromIssueType: tool.schema.string().optional(),
166
215
  notes: tool.schema.string().optional(),
167
216
  })).optional(),
168
- }).optional().describe("For upsertNarrative: canonical narrative state fields to merge into DECKS.json. Replaces provided arrays, preserves approvals."),
217
+ }).optional().describe("For initNarrativeVault and targeted vault actions: canonical narrative fields to write into Markdown nodes. upsertNarrative is deprecated."),
169
218
  design: tool.schema.string().optional().describe("For upsertDeck: active design name."),
170
219
  domain: tool.schema.string().optional().describe("For upsertDeck: active domain name."),
171
220
  memory: tool.schema.string().optional().describe("For remember: explicit user or workflow preference to store."),
@@ -249,7 +298,29 @@ export default tool({
249
298
  notes: tool.schema.string().optional().describe("Implementation notes for this slide."),
250
299
  })).optional().describe("For upsertSlides: complete or partial slide specs."),
251
300
  candidateIds: tool.schema.array(tool.schema.string()).optional().describe("For applyEvidenceCandidates: candidate IDs returned by revela-decks review to explicitly bind proposed evidenceDraft records into slide evidence."),
252
- findingsFile: tool.schema.string().optional().describe("For attachResearchFindings: workspace-relative researches/{topic}/{axis}.md file to attach to researchPlan."),
301
+ evidence: tool.schema.object({
302
+ id: tool.schema.string().describe("For upsertVaultEvidence or bindResearchFindings: canonical evidence binding id."),
303
+ claimId: tool.schema.string().describe("For upsertVaultEvidence: canonical claim id this evidence supports."),
304
+ source: tool.schema.string().describe("For upsertVaultEvidence: source name, file, URL, or research finding label."),
305
+ sourcePath: tool.schema.string().optional(),
306
+ findingsFile: tool.schema.string().optional(),
307
+ quote: tool.schema.string().describe("For upsertVaultEvidence: exact quote or snippet supporting the claim."),
308
+ location: tool.schema.string().optional(),
309
+ url: tool.schema.string().optional(),
310
+ caveat: tool.schema.string().describe("For upsertVaultEvidence: evidence limitation that must travel with the claim."),
311
+ supportScope: tool.schema.string().describe("For upsertVaultEvidence: scope explicitly supported by the evidence."),
312
+ unsupportedScope: tool.schema.string().describe("For upsertVaultEvidence: scope not supported by the evidence."),
313
+ strength: tool.schema.enum(["strong", "partial", "weak"]).describe("For upsertVaultEvidence: support strength."),
314
+ }).optional().describe("For upsertVaultEvidence: canonical evidence node to write under revela-narrative/evidence/*.md. For bindResearchFindings, only id is used as an optional override."),
315
+ relation: tool.schema.object({
316
+ id: tool.schema.string().describe("For upsertVaultRelation/removeVaultRelation: stable relation edge id."),
317
+ from: tool.schema.string().optional().describe("For upsertVaultRelation: source node id, e.g. claim:pilot or evidence:pilot."),
318
+ to: tool.schema.string().optional().describe("For upsertVaultRelation: target node id, e.g. claim:execution."),
319
+ type: tool.schema.enum(["leads_to", "supports", "depends_on", "contrasts_with", "constrains", "answers"]).optional().describe("For upsertVaultRelation: canonical relation type."),
320
+ rationale: tool.schema.string().optional().describe("Optional relation rationale. Do not invent; only preserve explicit semantic rationale."),
321
+ }).optional().describe("For deprecated upsertVaultRelation/removeVaultRelation compatibility actions. Inline ## Relations in node Markdown are canonical."),
322
+ relationId: tool.schema.string().optional().describe("For deprecated removeVaultRelation compatibility action."),
323
+ findingsFile: tool.schema.string().optional().describe("For attachResearchFindings, evaluateResearchFindings, or bindResearchFindings: workspace-relative researches/{topic}/{axis}.md findings file."),
253
324
  researchAxis: tool.schema.string().optional().describe("For attachResearchFindings: researchPlan axis to attach the findings file to. Required when filename matching would be ambiguous."),
254
325
  researchStatus: tool.schema.enum(["done", "read"]).optional().describe("For attachResearchFindings: optional explicit status to set on the matched research axis."),
255
326
  approvalNote: tool.schema.string().optional().describe("For approveNarrative or confirmDeckPlan: optional note explaining the approval, override, or deck plan confirmation."),
@@ -267,10 +338,12 @@ export default tool({
267
338
  evidenceBindingIds: tool.schema.array(tool.schema.string()).optional(),
268
339
  createdFromIssueType: tool.schema.string().optional(),
269
340
  notes: tool.schema.string().optional(),
270
- })).optional().describe("For upsertResearchGaps: explicit canonical research gaps to create or update."),
341
+ })).optional().describe("For upsertVaultResearchGap or legacy upsertResearchGaps: explicit canonical research gaps to create or update."),
271
342
  gapStatus: tool.schema.enum(["open", "in_progress", "findings_saved", "attached", "evidence_bound", "closed"]).optional().describe("For updateResearchGap: lifecycle status."),
272
343
  gapNotes: tool.schema.string().optional().describe("For updateResearchGap/closeResearchGap: notes or close reason."),
273
344
  evidenceBindingIds: tool.schema.array(tool.schema.string()).optional().describe("For updateResearchGap: canonical narrative evidence binding ids associated with the gap."),
345
+ markdownQaScope: tool.schema.enum(["touched", "affected", "full"]).optional().describe("For markdownQa: relation sync scope. Hook usage should stay touched; manual checks usually use full."),
346
+ markdownQaStrictness: tool.schema.enum(["authoring", "readiness", "render"]).optional().describe("For markdownQa: relation sync strictness. Render/readiness can upgrade relation sync gaps to blockers."),
274
347
  },
275
348
  async execute(args, context) {
276
349
  try {
@@ -280,6 +353,7 @@ export default tool({
280
353
 
281
354
  if (args.action === "init") {
282
355
  const discovered: SourceMaterial[] = []
356
+ const ingest = classifySourceMaterialIngest(state, (args.sourceMaterials ?? []) as SourceMaterial[], workspaceRoot, narrativeVaultTimestampMs(workspaceRoot))
283
357
  for (const material of (args.sourceMaterials ?? []) as SourceMaterial[]) {
284
358
  upsertSourceMaterial(state, material, material.status ?? "discovered")
285
359
  discovered.push(material)
@@ -295,18 +369,252 @@ export default tool({
295
369
  })
296
370
  }
297
371
  writeDecksState(workspaceRoot, state)
298
- return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, state }, null, 2)
372
+ return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, ingest, state }, null, 2)
299
373
  }
300
374
 
301
375
  if (args.action === "read") {
302
376
  const deckKey = state.activeDeck
303
377
  if (args.summary) {
304
378
  const deck = deckKey ? state.decks[deckKey] : undefined
305
- return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, activeDeck: state.activeDeck, deck }, null, 2)
379
+ const vaultDiagnostics = hasNarrativeVault(workspaceRoot)
380
+ ? formatVaultDiagnosticReport(compileNarrativeVault(workspaceRoot, { fallbackApprovals: state.narrative?.approvals ?? [] }).diagnostics)
381
+ : undefined
382
+ const migration = getNarrativeVaultMigrationHint(workspaceRoot, state)
383
+ const authoringContract = hasNarrativeVault(workspaceRoot) || migration.available ? narrativeVaultAuthoringContract() : undefined
384
+ const narrativeInventory = hasNarrativeVault(workspaceRoot) ? buildNarrativeVaultInventory(workspaceRoot) : undefined
385
+ const markdownQa = hasNarrativeVault(workspaceRoot) ? runNarrativeMarkdownQa(workspaceRoot) : undefined
386
+ return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, activeDeck: state.activeDeck, deck, vaultDiagnostics, markdownQa, migration, authoringContract, narrativeInventory }, null, 2)
306
387
  }
307
388
  return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, state }, null, 2)
308
389
  }
309
390
 
391
+ if (args.action === "narrativeInventory" || args.action === "vaultInventory") {
392
+ if (!hasNarrativeVault(workspaceRoot)) {
393
+ const migration = getNarrativeVaultMigrationHint(workspaceRoot, state)
394
+ return JSON.stringify({ ok: false, path: "revela-narrative", error: "narrativeInventory requires revela-narrative/ to exist. Use initNarrativeVault or exportNarrativeVault first.", migration, authoringContract: migration.available ? narrativeVaultAuthoringContract() : undefined }, null, 2)
395
+ }
396
+ const inventory = buildNarrativeVaultInventory(workspaceRoot)
397
+ return JSON.stringify({ ok: inventory.ok, path: inventory.path, narrativeInventory: inventory, authoringContract: narrativeVaultAuthoringContract() }, null, 2)
398
+ }
399
+
400
+ if (args.action === "markdownQa") {
401
+ if (!hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, path: "revela-narrative", error: "markdownQa requires revela-narrative/ to exist. Use initNarrativeVault or exportNarrativeVault first." }, null, 2)
402
+ const markdownQa = runNarrativeMarkdownQa(workspaceRoot, { scope: args.markdownQaScope ?? "full", strictness: args.markdownQaStrictness ?? "authoring" })
403
+ return JSON.stringify({ ok: markdownQa.ok, path: "revela-narrative", markdownQa, narrativeInventory: buildNarrativeVaultInventory(workspaceRoot), authoringContract: narrativeVaultAuthoringContract() }, null, 2)
404
+ }
405
+
406
+ if (args.action === "compileNarrativeVault") {
407
+ const compiled = compileCacheMirrorNarrativeVault(workspaceRoot, { state })
408
+ const { result, diagnosticReport } = compiled
409
+ const markdownQa = hasNarrativeVault(workspaceRoot) ? runNarrativeMarkdownQa(workspaceRoot) : undefined
410
+ const narrativeInventory = hasNarrativeVault(workspaceRoot) ? buildNarrativeVaultInventory(workspaceRoot) : undefined
411
+ return JSON.stringify({ ok: result.ok, path: DECKS_STATE_FILE, result, diagnosticReport, markdownQa, narrativeInventory }, null, 2)
412
+ }
413
+
414
+ if (args.action === "exportNarrativeVault") {
415
+ if (hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: "revela-narrative/ already exists. Edit Markdown nodes directly or move the existing vault before exporting." })
416
+ const narrative = state.narrative ?? normalizeNarrativeState(state)
417
+ const result = exportNarrativeStateToVault(workspaceRoot, narrative)
418
+ const compiled = compileCacheMirrorNarrativeVault(workspaceRoot, { state, fallbackApprovals: narrative.approvals })
419
+ return JSON.stringify({
420
+ ok: compiled.result.ok,
421
+ path: "revela-narrative",
422
+ result,
423
+ files: result.files,
424
+ diagnostics: compiled.result.diagnostics,
425
+ diagnosticReport: compiled.diagnosticReport,
426
+ migrationNote: "Exported canonical narrative nodes to revela-narrative/. Approvals, render targets, reviews, artifact coverage, actions, deck specs, and source material records remain in DECKS.json.",
427
+ preservedInDecksJson: VAULT_MIGRATION_PRESERVED_IN_DECKS_JSON,
428
+ nextActions: [
429
+ "Review diagnosticReport for any source trace, evidence, relation, or approval warnings.",
430
+ "Edit narrative meaning through targeted vault actions or Markdown nodes, then run compileNarrativeVault.",
431
+ "Keep approvals, render targets, reviews, artifact coverage, actions, deck specs, and source material records in DECKS.json.",
432
+ ],
433
+ }, null, 2)
434
+ }
435
+
436
+ if (args.action === "initNarrativeVault") {
437
+ if (hasNarrativeVault(workspaceRoot)) {
438
+ const compiled = compileCacheMirrorNarrativeVault(workspaceRoot, { state })
439
+ return JSON.stringify({ ok: true, path: "revela-narrative", created: false, files: [], diagnostics: compiled.result.diagnostics, diagnosticReport: compiled.diagnosticReport, narrativeInventory: buildNarrativeVaultInventory(workspaceRoot), authoringContract: narrativeVaultAuthoringContract(), nextActions: ["Inspect narrativeInventory before authoring; reuse existing ids and relation targets.", "Maintain Markdown nodes directly when useful, then run compileNarrativeVault. Use structured vault helpers only when they reduce schema risk."], narrative: compiled.result.narrative }, null, 2)
440
+ }
441
+ const result = initNarrativeVault(workspaceRoot, {
442
+ id: `narrative:${defaultSlug}`,
443
+ status: args.narrative?.status as any ?? "draft",
444
+ audience: args.narrative?.audience as any,
445
+ decision: args.narrative?.decision as any,
446
+ thesis: args.narrative?.thesis as any,
447
+ })
448
+ const compiled = compileCacheMirrorNarrativeVault(workspaceRoot, { state })
449
+ return JSON.stringify({ ok: compiled.result.ok, path: result.path, created: result.created, files: result.files, diagnostics: compiled.result.diagnostics, diagnosticReport: compiled.diagnosticReport, narrativeInventory: buildNarrativeVaultInventory(workspaceRoot), authoringContract: narrativeVaultAuthoringContract(), nextActions: ["Inspect narrativeInventory before adding claims, evidence, relations, or research gaps; reuse existing ids where possible.", "Maintain Markdown nodes directly when useful. Use structured vault helpers for evidence binding or when they reduce schema risk.", "Treat workspace source material records as candidates until explicit evidence trace is written."], narrative: compiled.result.narrative }, null, 2)
450
+ }
451
+
452
+ if (args.action === "upsertVaultEvidence") {
453
+ if (!hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: "upsertVaultEvidence requires revela-narrative/ to exist. Use initNarrativeVault first, then write explicit evidence source trace." })
454
+ if (!args.evidence) return JSON.stringify({ ok: false, error: "evidence is required for upsertVaultEvidence" })
455
+ const mutation = upsertVaultEvidenceNode(workspaceRoot, args.evidence as any)
456
+ if (!mutation.ok) return JSON.stringify({ ok: false, mutation }, null, 2)
457
+ const compiled = compileCacheMirrorNarrativeVault(workspaceRoot, { state })
458
+ return JSON.stringify({ ok: compiled.result.ok, path: mutation.file, mutation, diagnostics: compiled.result.diagnostics, diagnosticReport: compiled.diagnosticReport, narrative: compiled.result.narrative }, null, 2)
459
+ }
460
+
461
+ if (args.action === "bindResearchFindings") {
462
+ if (!hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: "bindResearchFindings requires revela-narrative/ to exist. Use initNarrativeVault first, then evaluateResearchFindings." })
463
+ if (!args.findingsFile?.trim()) return JSON.stringify({ ok: false, error: "findingsFile is required for bindResearchFindings" })
464
+ const bindingEval = evaluateResearchFindingsBinding(state, workspaceRoot, args.findingsFile)
465
+ if (bindingEval.status !== "bindable" || !bindingEval.claimId || !bindingEval.recommendedEvidenceDraft) {
466
+ return JSON.stringify({ ok: false, skipped: true, reason: "findings are not safely bindable", bindingEval }, null, 2)
467
+ }
468
+ const draft = bindingEval.recommendedEvidenceDraft
469
+ const evidence = {
470
+ id: args.evidence?.id?.trim() || stableEvidenceId(bindingEval.claimId, `${bindingEval.findingsFile}:${draft.quote ?? ""}`),
471
+ claimId: bindingEval.claimId,
472
+ source: draft.source,
473
+ sourcePath: draft.sourcePath,
474
+ findingsFile: draft.findingsFile ?? bindingEval.findingsFile,
475
+ quote: draft.quote,
476
+ location: draft.location,
477
+ url: draft.url,
478
+ caveat: draft.caveat,
479
+ supportScope: draft.supportScope,
480
+ unsupportedScope: draft.unsupportedScope,
481
+ strength: draft.strength,
482
+ }
483
+ const missing = missingBindableEvidenceFields(evidence)
484
+ if (missing.length > 0) return JSON.stringify({ ok: false, skipped: true, reason: "recommended evidence draft is incomplete", missingFields: missing, bindingEval }, null, 2)
485
+
486
+ const mutation = upsertVaultEvidenceNode(workspaceRoot, evidence as any)
487
+ if (!mutation.ok) return JSON.stringify({ ok: false, mutation, bindingEval }, null, 2)
488
+ const gap = exactResearchGapForBinding(state, bindingEval.findingsFile, bindingEval.claimId)
489
+ const gapMutation = gap
490
+ ? updateVaultResearchGapNode(workspaceRoot, {
491
+ id: gap.id,
492
+ status: "evidence_bound",
493
+ findingsFile: bindingEval.findingsFile,
494
+ evidenceBindingIds: [...new Set([...(gap.evidenceBindingIds ?? []), evidence.id])],
495
+ notes: gap.notes,
496
+ })
497
+ : undefined
498
+ const compiled = compileCacheMirrorNarrativeVault(workspaceRoot, { state })
499
+ return JSON.stringify({
500
+ ok: compiled.result.ok,
501
+ path: mutation.file,
502
+ bindingEval,
503
+ mutation,
504
+ gapMutation: gapMutation ?? { ok: true, skipped: true, reason: "no exact single research gap matched this findings file and claim" },
505
+ evidence,
506
+ diagnostics: compiled.result.diagnostics,
507
+ diagnosticReport: compiled.diagnosticReport,
508
+ narrative: compiled.result.narrative,
509
+ }, null, 2)
510
+ }
511
+
512
+ if (args.action === "updateVaultResearchGap") {
513
+ if (!hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: "updateVaultResearchGap requires revela-narrative/ to exist. Use updateResearchGap only in compatibility JSON workspaces." })
514
+ if (!args.gapId?.trim()) return JSON.stringify({ ok: false, error: "gapId is required for updateVaultResearchGap" })
515
+ const mutation = updateVaultResearchGapNode(workspaceRoot, {
516
+ ...(args.researchGaps?.[0] as any ?? {}),
517
+ id: args.gapId,
518
+ status: args.gapStatus as any,
519
+ findingsFile: args.findingsFile,
520
+ evidenceBindingIds: args.evidenceBindingIds,
521
+ notes: args.gapNotes,
522
+ })
523
+ if (!mutation.ok) return JSON.stringify({ ok: false, mutation, recovery: researchGapHelperRecovery("updateVaultResearchGap", mutation.missingFields), narrativeInventory: buildNarrativeVaultInventory(workspaceRoot), authoringContract: narrativeVaultAuthoringContract() }, null, 2)
524
+ const compiled = compileCacheMirrorNarrativeVault(workspaceRoot, { state })
525
+ return JSON.stringify({ ok: compiled.result.ok, path: mutation.file, mutation, diagnostics: compiled.result.diagnostics, diagnosticReport: compiled.diagnosticReport, narrative: compiled.result.narrative }, null, 2)
526
+ }
527
+
528
+ if (args.action === "upsertVaultResearchGap") {
529
+ if (!hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: "upsertVaultResearchGap requires revela-narrative/ to exist. Use initNarrativeVault first." })
530
+ const gaps = args.researchGaps?.length ? (args.researchGaps as any[]) : [undefined]
531
+ const mutations = []
532
+ for (const gap of gaps) {
533
+ const rawId = gap?.id ?? (gaps.length === 1 ? args.gapId : undefined)
534
+ const id = typeof rawId === "string" ? rawId.trim() : ""
535
+ if (!id) return JSON.stringify({ ok: false, error: "gapId or every researchGaps[].id is required for upsertVaultResearchGap", recovery: researchGapHelperRecovery("upsertVaultResearchGap"), narrativeInventory: buildNarrativeVaultInventory(workspaceRoot), authoringContract: narrativeVaultAuthoringContract() }, null, 2)
536
+ const mutation = updateVaultResearchGapNode(workspaceRoot, {
537
+ ...gap,
538
+ id,
539
+ status: args.gapStatus as any ?? gap?.status,
540
+ findingsFile: args.findingsFile ?? gap?.findingsFile,
541
+ evidenceBindingIds: args.evidenceBindingIds ?? gap?.evidenceBindingIds,
542
+ notes: args.gapNotes ?? gap?.notes,
543
+ })
544
+ if (!mutation.ok) return JSON.stringify({ ok: false, mutation, recovery: researchGapHelperRecovery("upsertVaultResearchGap", mutation.missingFields), narrativeInventory: buildNarrativeVaultInventory(workspaceRoot), authoringContract: narrativeVaultAuthoringContract() }, null, 2)
545
+ mutations.push(mutation)
546
+ }
547
+ const compiled = compileCacheMirrorNarrativeVault(workspaceRoot, { state })
548
+ return JSON.stringify({ ok: compiled.result.ok, path: mutations[0]?.file, mutation: mutations[0], mutations, diagnostics: compiled.result.diagnostics, diagnosticReport: compiled.diagnosticReport, authoringContract: narrativeVaultAuthoringContract(), narrative: compiled.result.narrative }, null, 2)
549
+ }
550
+
551
+ if (args.action === "upsertVaultRelation") {
552
+ if (!hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: "upsertVaultRelation requires revela-narrative/ to exist. Use initNarrativeVault first." })
553
+ const relation = args.relation as any
554
+ if (!relation?.id) return JSON.stringify({ ok: false, error: "relation.id is required for upsertVaultRelation", recovery: relationHelperRecovery("upsertVaultRelation"), narrativeInventory: buildNarrativeVaultInventory(workspaceRoot), authoringContract: narrativeVaultAuthoringContract() }, null, 2)
555
+ const mutation = upsertVaultRelation(workspaceRoot, {
556
+ id: relation.id,
557
+ fromId: relation.from,
558
+ toId: relation.to,
559
+ relation: relation.type,
560
+ rationale: relation.rationale,
561
+ })
562
+ if (!mutation.ok) return JSON.stringify({ ok: false, mutation, recovery: relationHelperRecovery("upsertVaultRelation", mutation.missingFields), narrativeInventory: buildNarrativeVaultInventory(workspaceRoot), authoringContract: narrativeVaultAuthoringContract() }, null, 2)
563
+ const compiled = compileCacheMirrorNarrativeVault(workspaceRoot, { state })
564
+ return JSON.stringify({ ok: compiled.result.ok, path: mutation.file, mutation, diagnostics: compiled.result.diagnostics, diagnosticReport: compiled.diagnosticReport, narrativeInventory: buildNarrativeVaultInventory(workspaceRoot), authoringContract: narrativeVaultAuthoringContract(), narrative: compiled.result.narrative }, null, 2)
565
+ }
566
+
567
+ if (args.action === "removeVaultRelation") {
568
+ if (!hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: "removeVaultRelation requires revela-narrative/ to exist. Use initNarrativeVault first." })
569
+ const relationId = args.relationId?.trim() || (args.relation as any)?.id?.trim()
570
+ if (!relationId) return JSON.stringify({ ok: false, error: "relationId or relation.id is required for removeVaultRelation", recovery: relationHelperRecovery("removeVaultRelation"), narrativeInventory: buildNarrativeVaultInventory(workspaceRoot), authoringContract: narrativeVaultAuthoringContract() }, null, 2)
571
+ const mutation = removeVaultRelation(workspaceRoot, relationId)
572
+ if (!mutation.ok) return JSON.stringify({ ok: false, mutation, recovery: relationHelperRecovery("removeVaultRelation", mutation.missingFields), narrativeInventory: buildNarrativeVaultInventory(workspaceRoot), authoringContract: narrativeVaultAuthoringContract() }, null, 2)
573
+ const compiled = compileCacheMirrorNarrativeVault(workspaceRoot, { state })
574
+ return JSON.stringify({ ok: compiled.result.ok, path: mutation.file, mutation, diagnostics: compiled.result.diagnostics, diagnosticReport: compiled.diagnosticReport, narrativeInventory: buildNarrativeVaultInventory(workspaceRoot), authoringContract: narrativeVaultAuthoringContract(), narrative: compiled.result.narrative }, null, 2)
575
+ }
576
+
577
+ if (args.action === "upsertVaultClaim") {
578
+ if (!hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: "upsertVaultClaim requires revela-narrative/ to exist. Use initNarrativeVault first." })
579
+ const claim = (args.narrative?.claims?.[0] as any) ?? undefined
580
+ if (!claim?.id) return JSON.stringify({ ok: false, error: "narrative.claims[0].id is required for upsertVaultClaim" })
581
+ const relationHint = args.narrative?.claimRelations?.length
582
+ ? "upsertVaultClaim does not write graph edges. Add explicit ## Relations lines in the source node after checking narrativeInventory; compileNarrativeVault will generate relation ids."
583
+ : undefined
584
+ const mutation = upsertVaultClaimNode(workspaceRoot, claim)
585
+ if (!mutation.ok) return JSON.stringify({ ok: false, mutation }, null, 2)
586
+ const compiled = compileCacheMirrorNarrativeVault(workspaceRoot, { state })
587
+ return JSON.stringify({ ok: compiled.result.ok, path: mutation.file, mutation, relationHint, diagnostics: compiled.result.diagnostics, diagnosticReport: compiled.diagnosticReport, narrative: compiled.result.narrative }, null, 2)
588
+ }
589
+
590
+ if (args.action === "upsertVaultObjection") {
591
+ if (!hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: "upsertVaultObjection requires revela-narrative/ to exist. Use initNarrativeVault first." })
592
+ const objection = (args.narrative?.objections?.[0] as any) ?? undefined
593
+ if (!objection?.id) return JSON.stringify({ ok: false, error: "narrative.objections[0].id is required for upsertVaultObjection" })
594
+ const mutation = upsertVaultObjectionNode(workspaceRoot, objection)
595
+ if (!mutation.ok) return JSON.stringify({ ok: false, mutation }, null, 2)
596
+ const compiled = compileCacheMirrorNarrativeVault(workspaceRoot, { state })
597
+ return JSON.stringify({ ok: compiled.result.ok, path: mutation.file, mutation, diagnostics: compiled.result.diagnostics, diagnosticReport: compiled.diagnosticReport, narrative: compiled.result.narrative }, null, 2)
598
+ }
599
+
600
+ if (args.action === "upsertVaultRisk") {
601
+ if (!hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: "upsertVaultRisk requires revela-narrative/ to exist. Use initNarrativeVault first." })
602
+ const risk = (args.narrative?.risks?.[0] as any) ?? undefined
603
+ if (!risk?.id) return JSON.stringify({ ok: false, error: "narrative.risks[0].id is required for upsertVaultRisk" })
604
+ const mutation = upsertVaultRiskNode(workspaceRoot, risk)
605
+ if (!mutation.ok) return JSON.stringify({ ok: false, mutation }, null, 2)
606
+ const compiled = compileCacheMirrorNarrativeVault(workspaceRoot, { state })
607
+ return JSON.stringify({ ok: compiled.result.ok, path: mutation.file, mutation, diagnostics: compiled.result.diagnostics, diagnosticReport: compiled.diagnosticReport, narrative: compiled.result.narrative }, null, 2)
608
+ }
609
+
610
+ if (args.action === "updateVaultCoreNarrative") {
611
+ if (!hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: "updateVaultCoreNarrative requires revela-narrative/ to exist. Use initNarrativeVault first." })
612
+ const mutation = updateVaultCoreNodes(workspaceRoot, args.narrative as any ?? {})
613
+ if (!mutation.ok) return JSON.stringify({ ok: false, mutation }, null, 2)
614
+ const compiled = compileCacheMirrorNarrativeVault(workspaceRoot, { state })
615
+ return JSON.stringify({ ok: compiled.result.ok, path: mutation.file, mutation, diagnostics: compiled.result.diagnostics, diagnosticReport: compiled.diagnosticReport, narrative: compiled.result.narrative }, null, 2)
616
+ }
617
+
310
618
  if (args.action === "upsertDeck") {
311
619
  const deckKey = defaultSlug
312
620
  const existing = state.decks[deckKey]
@@ -342,45 +650,12 @@ export default tool({
342
650
  }
343
651
 
344
652
  if (args.action === "upsertNarrative") {
345
- if (!args.narrative) return JSON.stringify({ ok: false, error: "narrative is required for upsertNarrative" })
346
- const current = state.narrative ?? normalizeNarrativeState(state)
347
- const merged = mergeNarrativeInput(current, args.narrative as Partial<NarrativeStateV1>)
348
- const normalized = normalizeCanonicalNarrativeState(merged, state.activeDeck ?? defaultSlug)
349
- if (!normalized) return JSON.stringify({ ok: false, error: "narrative could not be normalized" })
350
- state.narrative = normalized
351
-
352
- const deckKey = state.activeDeck
353
- if (deckKey && state.decks[deckKey]) {
354
- state = upsertDeck(state, {
355
- ...state.decks[deckKey],
356
- slug: deckKey,
357
- audience: normalized.audience.primary || state.decks[deckKey].audience,
358
- narrativeBrief: narrativeToBrief(normalized),
359
- })
360
- }
361
-
362
- recordWorkspaceAction(state, {
363
- type: "narrative.upserted",
364
- actor: "revela-decks",
365
- inputs: { hadExistingNarrative: Boolean(current), providedFields: Object.keys(args.narrative as object) },
366
- outputs: {
367
- narrativeId: normalized.id,
368
- status: normalized.status,
369
- claimCount: normalized.claims.length,
370
- evidenceBindingCount: normalized.evidenceBindings.length,
371
- objectionCount: normalized.objections.length,
372
- riskCount: normalized.risks.length,
373
- },
374
- status: "success",
375
- summary: `Updated canonical narrative state with ${normalized.claims.length} claim${normalized.claims.length === 1 ? "" : "s"}.`,
376
- nodeIds: [normalized.id],
377
- })
378
-
379
- writeDecksState(workspaceRoot, state)
380
- return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, narrative: state.narrative, deck: state.activeDeck ? state.decks[state.activeDeck] : undefined }, null, 2)
653
+ return JSON.stringify({ ok: false, deprecated: true, error: "upsertNarrative is deprecated. Use initNarrativeVault to create revela-narrative/, then update canonical narrative meaning through structured vault actions such as updateVaultCoreNarrative, upsertVaultClaim, upsertVaultEvidence, upsertVaultObjection, upsertVaultRisk, upsertVaultResearchGap, updateVaultResearchGap, or bindResearchFindings.", authoringContract: narrativeVaultAuthoringContract() })
381
654
  }
382
655
 
383
656
  if (args.action === "review") {
657
+ const qaGate = strictVaultMarkdownQaGate(workspaceRoot, "render", "review")
658
+ if (qaGate) return JSON.stringify(qaGate, null, 2)
384
659
  const reviewed = reviewDeckState(state, undefined, { workspaceRoot })
385
660
  const targetId = activeReviewTargetId(reviewed.state)
386
661
  const snapshot = latestReviewSnapshotForTarget(reviewed.state, targetId)
@@ -409,6 +684,8 @@ export default tool({
409
684
  }
410
685
 
411
686
  if (args.action === "compileDeckPlan") {
687
+ const qaGate = strictVaultMarkdownQaGate(workspaceRoot, "render", "compileDeckPlan")
688
+ if (qaGate) return JSON.stringify(qaGate, null, 2)
412
689
  const compiled = compileDeckPlanFromNarrative(state)
413
690
  if (compiled.result.compiled) {
414
691
  recordWorkspaceAction(compiled.state, {
@@ -461,6 +738,8 @@ export default tool({
461
738
  }
462
739
 
463
740
  if (args.action === "reviewNarrative") {
741
+ const qaGate = strictVaultMarkdownQaGate(workspaceRoot, "readiness", "reviewNarrative")
742
+ if (qaGate) return JSON.stringify(qaGate, null, 2)
464
743
  const reviewed = reviewNarrativeState(state)
465
744
  recordNarrativeReviewAction(reviewed.state, reviewed.result)
466
745
  writeDecksState(workspaceRoot, reviewed.state)
@@ -468,6 +747,8 @@ export default tool({
468
747
  }
469
748
 
470
749
  if (args.action === "approveNarrative") {
750
+ const qaGate = strictVaultMarkdownQaGate(workspaceRoot, "readiness", "approveNarrative")
751
+ if (qaGate) return JSON.stringify(qaGate, null, 2)
471
752
  const approved = approveNarrativeState(state, {
472
753
  approvedBy: args.approvalBy,
473
754
  scope: args.approvalScope,
@@ -479,6 +760,7 @@ export default tool({
479
760
  }
480
761
 
481
762
  if (args.action === "deriveResearchGaps") {
763
+ if (hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: forbiddenVaultCompatibilityAction("deriveResearchGaps", "deriveResearchTargets for read-only target selection, then upsertVaultResearchGap for canonical gap nodes"), authoringContract: narrativeVaultAuthoringContract() })
482
764
  const derived = deriveResearchGapsFromReadiness(state)
483
765
  writeDecksState(workspaceRoot, derived.state)
484
766
  return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result: derived.result, narrative: derived.state.narrative }, null, 2)
@@ -489,7 +771,18 @@ export default tool({
489
771
  return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result }, null, 2)
490
772
  }
491
773
 
774
+ if (args.action === "evaluateResearchFindings") {
775
+ if (!args.findingsFile?.trim()) return JSON.stringify({ ok: false, error: "findingsFile is required for evaluateResearchFindings" })
776
+ const bindingEval = evaluateResearchFindingsBinding(state, workspaceRoot, args.findingsFile)
777
+ const targets = deriveResearchTargets(state, { workspaceRoot })
778
+ const vaultDiagnostics = hasNarrativeVault(workspaceRoot)
779
+ ? formatVaultDiagnosticReport(compileNarrativeVault(workspaceRoot, { fallbackApprovals: state.narrative?.approvals ?? [] }).diagnostics)
780
+ : undefined
781
+ return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result: { bindingEval, selected: targets.selected, vaultDiagnostics } }, null, 2)
782
+ }
783
+
492
784
  if (args.action === "upsertResearchGaps") {
785
+ if (hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: forbiddenVaultCompatibilityAction("upsertResearchGaps", "upsertVaultResearchGap for new or updated research gap nodes"), authoringContract: narrativeVaultAuthoringContract() })
493
786
  if (!args.researchGaps?.length) return JSON.stringify({ ok: false, error: "researchGaps are required for upsertResearchGaps" })
494
787
  const upserted = upsertResearchGapsInState(state, args.researchGaps as any[])
495
788
  writeDecksState(workspaceRoot, upserted.state)
@@ -497,6 +790,7 @@ export default tool({
497
790
  }
498
791
 
499
792
  if (args.action === "updateResearchGap") {
793
+ if (hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: forbiddenVaultCompatibilityAction("updateResearchGap", "updateVaultResearchGap or upsertVaultResearchGap"), authoringContract: narrativeVaultAuthoringContract() })
500
794
  if (!args.gapId?.trim()) return JSON.stringify({ ok: false, error: "gapId is required for updateResearchGap" })
501
795
  const updated = updateResearchGapInState(state, {
502
796
  id: args.gapId,
@@ -510,6 +804,7 @@ export default tool({
510
804
  }
511
805
 
512
806
  if (args.action === "closeResearchGap") {
807
+ if (hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: forbiddenVaultCompatibilityAction("closeResearchGap", "updateVaultResearchGap with gapStatus=closed"), authoringContract: narrativeVaultAuthoringContract() })
513
808
  if (!args.gapId?.trim()) return JSON.stringify({ ok: false, error: "gapId is required for closeResearchGap" })
514
809
  const closed = closeResearchGapInState(state, args.gapId, args.gapNotes)
515
810
  writeDecksState(workspaceRoot, closed.state)
@@ -517,6 +812,7 @@ export default tool({
517
812
  }
518
813
 
519
814
  if (args.action === "applyEvidenceCandidates") {
815
+ if (hasNarrativeVault(workspaceRoot)) return JSON.stringify({ ok: false, error: forbiddenVaultCompatibilityAction("applyEvidenceCandidates", "bindResearchFindings for bindable findings or upsertVaultEvidence for explicit evidence source trace"), authoringContract: narrativeVaultAuthoringContract() })
520
816
  const candidateIds = args.candidateIds ?? []
521
817
  if (candidateIds.length === 0) return JSON.stringify({ ok: false, error: "candidateIds are required for applyEvidenceCandidates" })
522
818
  const result = applyEvidenceBindings(workspaceRoot, candidateIds)
@@ -2,6 +2,7 @@ import { tool } from "@opencode-ai/plugin"
2
2
  import { mkdirSync, writeFileSync } from "fs"
3
3
  import { join } from "path"
4
4
  import { hasDecksState, readDecksState, writeDecksState } from "../lib/decks-state"
5
+ import { evaluateResearchFindingsBinding } from "../lib/narrative-state/research-binding-eval"
5
6
  import { recordWorkspaceAction } from "../lib/workspace-state/actions"
6
7
 
7
8
  /**
@@ -100,7 +101,9 @@ export default tool({
100
101
  summary: `Saved research findings for ${topicKey}/${fileKey}.`,
101
102
  nodeIds: [`finding:${relPath}`],
102
103
  })
104
+ const bindingEval = evaluateResearchFindingsBinding(state, workspaceDir, relPath)
103
105
  writeDecksState(workspaceDir, state)
106
+ return JSON.stringify({ ok: true, path: relPath, bindingEval })
104
107
  }
105
108
 
106
109
  return JSON.stringify({ ok: true, path: relPath })
@@ -15,6 +15,7 @@ const DOC_EXTENSIONS = new Set([
15
15
  const EXCLUDE_DIRS = new Set([
16
16
  "node_modules", ".git", "dist", ".opencode",
17
17
  "researches", // Exclude revela's own research output
18
+ "revela-narrative", // Exclude canonical narrative vault source files
18
19
  "designs", "domains", // Exclude revela plugin assets
19
20
  ])
20
21