@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
package/plugin.ts CHANGED
@@ -48,6 +48,7 @@ import { buildPptxNotesPrompt, handlePptx, parsePptxArgs, resolvePptxDeck } from
48
48
  import { handleEdit } from "./lib/commands/edit"
49
49
  import { handleInspect } from "./lib/commands/inspect"
50
50
  import { handleRefine } from "./lib/commands/refine"
51
+ import { formatDeckHtmlContractReport, validateDeckHtmlContract } from "./lib/deck-html/contract"
51
52
  import { ensureEditableDeckOpenForChange } from "./lib/edit/open"
52
53
  import { hasLiveEditorSessionForFile } from "./lib/edit/server"
53
54
  import { handleDesignsPreview } from "./lib/commands/designs-preview"
@@ -59,7 +60,7 @@ import {
59
60
  } from "./lib/commands/designs-new"
60
61
  import { buildInitPrompt } from "./lib/commands/init"
61
62
  import { parseRememberArgs, buildRememberPrompt } from "./lib/commands/remember"
62
- import { buildReviewPrompt } from "./lib/commands/review"
63
+ import { buildDeckPrompt, buildDeckReviewPrompt, buildReviewPrompt } from "./lib/commands/review"
63
64
  import {
64
65
  extractDeckHtmlTargetsFromPatch,
65
66
  extractPatchTextArg,
@@ -177,6 +178,27 @@ const server: Plugin = (async (pluginCtx) => {
177
178
  }
178
179
  }
179
180
 
181
+ async function appendDeckHtmlContractReport(filePath: string, output: any): Promise<void> {
182
+ if (!isDeckHtmlPath(filePath)) return
183
+
184
+ try {
185
+ const report = validateDeckHtmlContract(workspaceRoot, filePath)
186
+ if (report.status === "valid" || report.status === "skipped") return
187
+
188
+ appendToolResult(
189
+ output,
190
+ "---\n\n**[revela deck HTML contract]** Slide identity check failed:\n\n" +
191
+ formatDeckHtmlContractReport(report) +
192
+ "\n\nFix every `<section class=\"slide\">` to use the matching 1-based `data-slide-index` from DECKS.json before inspection or export."
193
+ )
194
+ } catch (e) {
195
+ childLog("deck-contract").warn("deck HTML contract report failed", {
196
+ filePath,
197
+ error: e instanceof Error ? e.message : String(e),
198
+ })
199
+ }
200
+ }
201
+
180
202
  function extractSessionID(input: any): string {
181
203
  return input?.sessionID ?? input?.session?.id ?? input?.context?.sessionID ?? ""
182
204
  }
@@ -304,6 +326,7 @@ const server: Plugin = (async (pluginCtx) => {
304
326
  throw new Error("__REVELA_DISABLE_HANDLED__")
305
327
  }
306
328
  if (sub === "init") {
329
+ buildPrompt({ mode: "narrative" })
307
330
  output.parts.length = 0
308
331
  output.parts.push({
309
332
  type: "text",
@@ -317,6 +340,7 @@ const server: Plugin = (async (pluginCtx) => {
317
340
  await send(parsed.error)
318
341
  throw new Error("__REVELA_REMEMBER_USAGE_HANDLED__")
319
342
  }
343
+ buildPrompt({ mode: "narrative" })
320
344
  output.parts.length = 0
321
345
  output.parts.push({
322
346
  type: "text",
@@ -326,9 +350,10 @@ const server: Plugin = (async (pluginCtx) => {
326
350
  }
327
351
  if (sub === "review") {
328
352
  if (param) {
329
- await send("`/revela review` no longer accepts a deck name. It reviews the current workspace deck.")
353
+ await send("`/revela review` no longer accepts a deck name. It reviews the current workspace narrative. Use `/revela deck --review` for deck/artifact readiness.")
330
354
  throw new Error("__REVELA_REVIEW_USAGE_HANDLED__")
331
355
  }
356
+ buildPrompt({ mode: "narrative" })
332
357
  output.parts.length = 0
333
358
  output.parts.push({
334
359
  type: "text",
@@ -336,6 +361,28 @@ const server: Plugin = (async (pluginCtx) => {
336
361
  } as any)
337
362
  return
338
363
  }
364
+ if (sub === "deck") {
365
+ if (param && param !== "--review") {
366
+ await send("Usage: `/revela deck` starts approved-narrative deck handoff; `/revela deck --review` reviews deck/artifact readiness.")
367
+ throw new Error("__REVELA_DECK_USAGE_HANDLED__")
368
+ }
369
+ if (!param) {
370
+ buildPrompt({ mode: "deck-render" })
371
+ output.parts.length = 0
372
+ output.parts.push({
373
+ type: "text",
374
+ text: buildDeckPrompt({ exists: hasDecksState(workspaceRoot), workspaceRoot }),
375
+ } as any)
376
+ return
377
+ }
378
+ buildPrompt({ mode: "deck-render" })
379
+ output.parts.length = 0
380
+ output.parts.push({
381
+ type: "text",
382
+ text: buildDeckReviewPrompt({ exists: hasDecksState(workspaceRoot), workspaceRoot }),
383
+ } as any)
384
+ return
385
+ }
339
386
  if (sub === "refine") {
340
387
  if (param) {
341
388
  await send("`/revela refine` does not accept a target. It opens the only HTML deck in `decks/`.")
@@ -423,7 +470,7 @@ const server: Plugin = (async (pluginCtx) => {
423
470
  throw new Error("__REVELA_DOMAINS_RM_HANDLED__")
424
471
  }
425
472
  if (sub === "pdf") {
426
- await handlePdf(param, send)
473
+ await handlePdf(param, send, workspaceRoot)
427
474
  throw new Error("__REVELA_PDF_HANDLED__")
428
475
  }
429
476
  if (sub === "pptx") {
@@ -742,6 +789,7 @@ Next step: use \`revela-decks\` or \`/revela review\` to update ${DECKS_STATE_FI
742
789
  return
743
790
  }
744
791
  await appendComplianceReport(filePath, output)
792
+ await appendDeckHtmlContractReport(filePath, output)
745
793
  ensureEditorOpenAfterDeckChange(filePath, extractSessionID(input))
746
794
  return
747
795
  }
@@ -763,6 +811,7 @@ Next step: use \`revela-decks\` or \`/revela review\` to update ${DECKS_STATE_FI
763
811
  const targets = patchText ? extractDeckHtmlTargetsFromPatch(patchText) : []
764
812
  for (const target of targets) {
765
813
  await appendComplianceReport(target, output)
814
+ await appendDeckHtmlContractReport(target, output)
766
815
  ensureEditorOpenAfterDeckChange(target, extractSessionID(input))
767
816
  }
768
817
  return
@@ -771,6 +820,7 @@ Next step: use \`revela-decks\` or \`/revela review\` to update ${DECKS_STATE_FI
771
820
  if (input.tool === "edit") {
772
821
  const filePath = extractEditFilePath(input.args)
773
822
  await appendComplianceReport(filePath, output)
823
+ await appendDeckHtmlContractReport(filePath, output)
774
824
  ensureEditorOpenAfterDeckChange(filePath, extractSessionID(input))
775
825
  return
776
826
  }
@@ -0,0 +1,64 @@
1
+ ---
2
+ name: revela-narrative
3
+ description: Build trusted narrative readiness before rendering deck artifacts
4
+ compatibility: opencode
5
+ ---
6
+
7
+ # Revela — Narrative Workspace
8
+
9
+ You help the user turn source materials, research, and intent into a trusted communication narrative before any deck is rendered.
10
+
11
+ Default mode is narrative-first. Do not generate HTML slides, choose visual layouts, fetch design components, or ask for slide count unless the user explicitly enters a deck-render workflow.
12
+
13
+ ## Core Job
14
+
15
+ Build and review the narrative state around:
16
+ - primary audience and stakeholder context
17
+ - audience belief before and desired belief after
18
+ - decision or action required
19
+ - thesis or central recommendation
20
+ - central claims and their evidence boundaries
21
+ - objections, risks, assumptions, caveats, and unsupported scope
22
+ - narrative approval state and whether approval is stale
23
+
24
+ ## Workspace State
25
+
26
+ Use `DECKS.json` as Revela's current compatibility workspace-state file. Do not write or patch it directly.
27
+
28
+ Use `revela-decks` for state operations:
29
+ - `read` to inspect current workspace state
30
+ - `init` to register discovered source material candidates during workspace initialization
31
+ - `upsertNarrative` to preserve canonical audience, decision, thesis, claims, evidence bindings, objections, and risks
32
+ - `upsertDeck` or `upsertSlides` only when explicitly needed by a deck/artifact workflow prompt
33
+ - `reviewNarrative` to run deterministic narrative readiness
34
+ - `approveNarrative` only when the user explicitly approves or requests an override
35
+
36
+ Never treat `writeReadiness.status`, old review snapshots, existing `decks/*.html`, or saved research actions as narrative approval.
37
+
38
+ ## Narrative Review Rules
39
+
40
+ When reviewing, call `revela-decks` action `reviewNarrative` and report the tool result as authoritative.
41
+
42
+ Use this report shape:
43
+ - `Narrative readiness: <status>`
44
+ - `Narrative hash: <hash>` when available
45
+ - blockers first, with issue type, claim text when available, and suggested next action
46
+ - warnings second, as residual risks
47
+ - approval state last, clearly distinguishing `ready_for_approval`, `approved`, stale approval, and render override
48
+
49
+ If evidence is missing, say what is missing and what should happen next. Do not invent quotes, sources, page locations, URLs, caveats, or research findings.
50
+
51
+ If research findings were saved but not attached or bound, describe them as unattached research state, not proof.
52
+
53
+ If the narrative is ready for approval, ask the user whether to approve or revise it. Do not approve automatically.
54
+
55
+ ## Boundaries
56
+
57
+ - Do not write or overwrite `decks/*.html` in narrative mode.
58
+ - Do not call `revela-decks review` in narrative mode; that is the deck/artifact gate.
59
+ - Do not apply evidence candidates, bind evidence, or rewrite slide text unless the user explicitly asks.
60
+ - Do not fetch design CSS, layouts, components, chart rules, or HTML skeletons in narrative mode.
61
+ - Do not store secrets, credentials, tokens, or sensitive personal information.
62
+ - Do not infer long-term user preferences from one-off tasks.
63
+
64
+ When the user wants deck/artifact readiness, direct them to `/revela deck --review`. When they want to render a deck, wait for the explicit deck workflow.
package/tools/decks.ts CHANGED
@@ -1,6 +1,5 @@
1
1
  import { tool } from "@opencode-ai/plugin"
2
2
  import {
3
- applyEvidenceCandidates,
4
3
  createDeckSpec,
5
4
  DECKS_STATE_FILE,
6
5
  normalizeWorkspaceDeckState,
@@ -18,15 +17,53 @@ import {
18
17
  type SlideSpec,
19
18
  } from "../lib/decks-state"
20
19
  import { upsertSourceMaterial } from "../lib/source-materials"
20
+ import { recordWorkspaceAction } from "../lib/workspace-state/actions"
21
+ import { applyEvidenceBindings } from "../lib/workspace-state/evidence-status"
22
+ import { attachResearchFindings } from "../lib/workspace-state/research-attachments"
23
+ import { activeReviewTargetId, latestReviewSnapshotForTarget } from "../lib/workspace-state/review-snapshots"
24
+ import {
25
+ approveNarrativeState,
26
+ recordNarrativeApprovalAction,
27
+ recordNarrativeReviewAction,
28
+ reviewNarrativeState,
29
+ } from "../lib/narrative-state/readiness"
30
+ import { compileDeckPlanFromNarrative } from "../lib/narrative-state/render-plan"
31
+ import { normalizeCanonicalNarrativeState, normalizeNarrativeState } from "../lib/narrative-state/normalize"
32
+ import { narrativeToBrief } from "../lib/narrative-state/project-compat"
33
+ import type { NarrativeStateV1 } from "../lib/narrative-state/types"
34
+
35
+ function mergeNarrativeInput(current: NarrativeStateV1, input: Partial<NarrativeStateV1>): Partial<NarrativeStateV1> {
36
+ return {
37
+ ...current,
38
+ ...input,
39
+ id: current.id,
40
+ version: 1,
41
+ audience: {
42
+ ...current.audience,
43
+ ...(input.audience ?? {}),
44
+ },
45
+ decision: {
46
+ ...current.decision,
47
+ ...(input.decision ?? {}),
48
+ },
49
+ thesis: input.thesis ? { ...current.thesis, ...input.thesis } as NarrativeStateV1["thesis"] : current.thesis,
50
+ claims: input.claims ?? current.claims,
51
+ evidenceBindings: input.evidenceBindings ?? current.evidenceBindings,
52
+ objections: input.objections ?? current.objections,
53
+ risks: input.risks ?? current.risks,
54
+ approvals: current.approvals,
55
+ updatedAt: new Date().toISOString(),
56
+ }
57
+ }
21
58
 
22
59
  export default tool({
23
60
  description:
24
61
  `Read and update ${DECKS_STATE_FILE}, Revela's workspace deck state file. ` +
25
62
  "Use this tool instead of writing or patching the state file directly. " +
26
- "It stores active deck specs, per-slide content/layout/components, and computes write readiness.",
63
+ "It stores workspace narrative state, active deck specs, per-slide content/layout/components, and computes narrative or deck readiness.",
27
64
  args: {
28
65
  action: tool.schema
29
- .enum(["read", "init", "upsertDeck", "upsertSlides", "review", "applyEvidenceCandidates", "remember"])
66
+ .enum(["read", "init", "upsertDeck", "upsertSlides", "upsertNarrative", "compileDeckPlan", "review", "reviewNarrative", "approveNarrative", "applyEvidenceCandidates", "attachResearchFindings", "remember"])
30
67
  .describe("Action to perform on DECKS.json."),
31
68
  summary: tool.schema.boolean().optional().describe("For read: return a compact summary instead of full state."),
32
69
  goal: tool.schema.string().optional().describe("For upsertDeck: deck goal."),
@@ -42,6 +79,69 @@ export default tool({
42
79
  objections: tool.schema.array(tool.schema.string()).optional().describe("Likely stakeholder objections or questions the narrative should handle."),
43
80
  risks: tool.schema.array(tool.schema.string()).optional().describe("Risks, assumptions, caveats, or tradeoffs that should travel with the narrative."),
44
81
  }).optional().describe("For upsertDeck: 0.9 Narrative Compiler brief used to review story intent before writing."),
82
+ narrative: tool.schema.object({
83
+ status: tool.schema.enum(["draft", "needs_research", "needs_user_confirmation", "ready_for_approval", "approved"]).optional(),
84
+ audience: tool.schema.object({
85
+ primary: tool.schema.string().optional(),
86
+ secondary: tool.schema.array(tool.schema.string()).optional(),
87
+ beliefBefore: tool.schema.string().optional(),
88
+ beliefAfter: tool.schema.string().optional(),
89
+ decisionContext: tool.schema.string().optional(),
90
+ successCriteria: tool.schema.array(tool.schema.string()).optional(),
91
+ }).optional(),
92
+ decision: tool.schema.object({
93
+ action: tool.schema.string().optional(),
94
+ owner: tool.schema.string().optional(),
95
+ deadline: tool.schema.string().optional(),
96
+ decisionType: tool.schema.enum(["approve", "invest", "prioritize", "align", "choose", "understand", "other"]).optional(),
97
+ consequenceOfNoDecision: tool.schema.string().optional(),
98
+ }).optional(),
99
+ thesis: tool.schema.object({
100
+ id: tool.schema.string().optional(),
101
+ statement: tool.schema.string().optional(),
102
+ confidence: tool.schema.enum(["high", "medium", "low"]).optional(),
103
+ caveat: tool.schema.string().optional(),
104
+ }).optional(),
105
+ claims: tool.schema.array(tool.schema.object({
106
+ id: tool.schema.string().optional(),
107
+ kind: tool.schema.enum(["context", "problem", "opportunity", "evidence", "recommendation", "risk", "assumption", "ask"]).optional(),
108
+ text: tool.schema.string().describe("Claim text."),
109
+ importance: tool.schema.enum(["central", "supporting", "background"]).optional(),
110
+ evidenceRequired: tool.schema.boolean().optional(),
111
+ evidenceStatus: tool.schema.enum(["supported", "partial", "weak", "missing", "not_required"]).optional(),
112
+ supportedScope: tool.schema.string().optional(),
113
+ unsupportedScope: tool.schema.string().optional(),
114
+ caveats: tool.schema.array(tool.schema.string()).optional(),
115
+ })).optional(),
116
+ evidenceBindings: tool.schema.array(tool.schema.object({
117
+ id: tool.schema.string().optional(),
118
+ claimId: tool.schema.string().describe("Canonical claim id this evidence supports."),
119
+ source: tool.schema.string().describe("Source file, URL, research finding, or material name."),
120
+ sourcePath: tool.schema.string().optional(),
121
+ findingsFile: tool.schema.string().optional(),
122
+ quote: tool.schema.string().optional(),
123
+ location: tool.schema.string().optional(),
124
+ url: tool.schema.string().optional(),
125
+ caveat: tool.schema.string().optional(),
126
+ supportScope: tool.schema.string().optional(),
127
+ unsupportedScope: tool.schema.string().optional(),
128
+ strength: tool.schema.enum(["strong", "partial", "weak"]).optional(),
129
+ })).optional(),
130
+ objections: tool.schema.array(tool.schema.object({
131
+ id: tool.schema.string().optional(),
132
+ text: tool.schema.string().describe("Objection text."),
133
+ claimId: tool.schema.string().optional(),
134
+ priority: tool.schema.enum(["high", "medium", "low"]).optional(),
135
+ response: tool.schema.string().optional(),
136
+ })).optional(),
137
+ risks: tool.schema.array(tool.schema.object({
138
+ id: tool.schema.string().optional(),
139
+ text: tool.schema.string().describe("Risk, assumption, caveat, or tradeoff text."),
140
+ claimId: tool.schema.string().optional(),
141
+ severity: tool.schema.enum(["high", "medium", "low"]).optional(),
142
+ mitigation: tool.schema.string().optional(),
143
+ })).optional(),
144
+ }).optional().describe("For upsertNarrative: canonical narrative state fields to merge into DECKS.json. Replaces provided arrays, preserves approvals."),
45
145
  design: tool.schema.string().optional().describe("For upsertDeck: active design name."),
46
146
  domain: tool.schema.string().optional().describe("For upsertDeck: active domain name."),
47
147
  memory: tool.schema.string().optional().describe("For remember: explicit user or workflow preference to store."),
@@ -118,6 +218,12 @@ export default tool({
118
218
  notes: tool.schema.string().optional().describe("Implementation notes for this slide."),
119
219
  })).optional().describe("For upsertSlides: complete or partial slide specs."),
120
220
  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."),
221
+ findingsFile: tool.schema.string().optional().describe("For attachResearchFindings: workspace-relative researches/{topic}/{axis}.md file to attach to researchPlan."),
222
+ researchAxis: tool.schema.string().optional().describe("For attachResearchFindings: researchPlan axis to attach the findings file to. Required when filename matching would be ambiguous."),
223
+ researchStatus: tool.schema.enum(["done", "read"]).optional().describe("For attachResearchFindings: optional explicit status to set on the matched research axis."),
224
+ approvalNote: tool.schema.string().optional().describe("For approveNarrative: optional note explaining the approval or override."),
225
+ approvalBy: tool.schema.enum(["user", "override"]).optional().describe("For approveNarrative: use override only for explicit render overrides, not normal strategic approval."),
226
+ approvalScope: tool.schema.enum(["narrative", "render_override"]).optional().describe("For approveNarrative: narrative approval or explicit render override scope."),
121
227
  },
122
228
  async execute(args, context) {
123
229
  try {
@@ -126,8 +232,20 @@ export default tool({
126
232
  const defaultSlug = workspaceDeckSlug(workspaceRoot)
127
233
 
128
234
  if (args.action === "init") {
235
+ const discovered: SourceMaterial[] = []
129
236
  for (const material of (args.sourceMaterials ?? []) as SourceMaterial[]) {
130
237
  upsertSourceMaterial(state, material, material.status ?? "discovered")
238
+ discovered.push(material)
239
+ }
240
+ if (discovered.length > 0) {
241
+ recordWorkspaceAction(state, {
242
+ type: "source.discovered",
243
+ actor: "revela-decks",
244
+ inputs: { count: discovered.length },
245
+ outputs: { paths: discovered.map((material) => material.path), statuses: discovered.map((material) => material.status ?? "discovered") },
246
+ summary: `Registered ${discovered.length} discovered source material${discovered.length === 1 ? "" : "s"}.`,
247
+ nodeIds: discovered.map((material) => `source:${material.path}`),
248
+ })
131
249
  }
132
250
  writeDecksState(workspaceRoot, state)
133
251
  return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, state }, null, 2)
@@ -176,18 +294,127 @@ export default tool({
176
294
  return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, deck: next.activeDeck ? next.decks[next.activeDeck] : undefined }, null, 2)
177
295
  }
178
296
 
297
+ if (args.action === "upsertNarrative") {
298
+ if (!args.narrative) return JSON.stringify({ ok: false, error: "narrative is required for upsertNarrative" })
299
+ const current = state.narrative ?? normalizeNarrativeState(state)
300
+ const merged = mergeNarrativeInput(current, args.narrative as Partial<NarrativeStateV1>)
301
+ const normalized = normalizeCanonicalNarrativeState(merged, state.activeDeck ?? defaultSlug)
302
+ if (!normalized) return JSON.stringify({ ok: false, error: "narrative could not be normalized" })
303
+ state.narrative = normalized
304
+
305
+ const deckKey = state.activeDeck
306
+ if (deckKey && state.decks[deckKey]) {
307
+ state = upsertDeck(state, {
308
+ ...state.decks[deckKey],
309
+ slug: deckKey,
310
+ audience: normalized.audience.primary || state.decks[deckKey].audience,
311
+ narrativeBrief: narrativeToBrief(normalized),
312
+ })
313
+ }
314
+
315
+ recordWorkspaceAction(state, {
316
+ type: "narrative.upserted",
317
+ actor: "revela-decks",
318
+ inputs: { hadExistingNarrative: Boolean(current), providedFields: Object.keys(args.narrative as object) },
319
+ outputs: {
320
+ narrativeId: normalized.id,
321
+ status: normalized.status,
322
+ claimCount: normalized.claims.length,
323
+ evidenceBindingCount: normalized.evidenceBindings.length,
324
+ objectionCount: normalized.objections.length,
325
+ riskCount: normalized.risks.length,
326
+ },
327
+ status: "success",
328
+ summary: `Updated canonical narrative state with ${normalized.claims.length} claim${normalized.claims.length === 1 ? "" : "s"}.`,
329
+ nodeIds: [normalized.id],
330
+ })
331
+
332
+ writeDecksState(workspaceRoot, state)
333
+ return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, narrative: state.narrative, deck: state.activeDeck ? state.decks[state.activeDeck] : undefined }, null, 2)
334
+ }
335
+
179
336
  if (args.action === "review") {
180
337
  const reviewed = reviewDeckState(state, undefined, { workspaceRoot })
338
+ const targetId = activeReviewTargetId(reviewed.state)
339
+ const snapshot = latestReviewSnapshotForTarget(reviewed.state, targetId)
340
+ recordWorkspaceAction(reviewed.state, {
341
+ type: "review.performed",
342
+ actor: "revela-decks",
343
+ inputs: { activeDeck: state.activeDeck },
344
+ outputs: {
345
+ slug: reviewed.result.slug,
346
+ status: reviewed.result.status,
347
+ ready: reviewed.result.ready,
348
+ blockerCount: reviewed.result.blockers.length,
349
+ warningCount: reviewed.result.warnings.length,
350
+ issueCount: reviewed.result.issues.length,
351
+ evidenceCandidateCount: reviewed.result.evidenceCandidates?.length ?? 0,
352
+ snapshotId: snapshot?.id,
353
+ inputHash: snapshot?.inputHash,
354
+ targetId: snapshot?.targetId,
355
+ },
356
+ status: "success",
357
+ summary: `Reviewed deck readiness: ${reviewed.result.ready ? "ready" : "blocked"}.`,
358
+ nodeIds: [`artifact:${reviewed.state.decks[reviewed.result.slug]?.outputPath ?? reviewed.result.slug}`, ...(snapshot ? [snapshot.id] : [])],
359
+ })
181
360
  writeDecksState(workspaceRoot, reviewed.state)
182
361
  return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result: reviewed.result }, null, 2)
183
362
  }
184
363
 
364
+ if (args.action === "compileDeckPlan") {
365
+ const compiled = compileDeckPlanFromNarrative(state)
366
+ if (compiled.result.compiled) {
367
+ recordWorkspaceAction(compiled.state, {
368
+ type: "deck.plan_compiled",
369
+ actor: "revela-decks",
370
+ inputs: { narrativeId: compiled.state.narrative?.id, activeDeck: compiled.state.activeDeck },
371
+ outputs: {
372
+ narrativeHash: compiled.result.narrativeHash,
373
+ slideCount: compiled.result.slideCount,
374
+ outputPath: compiled.state.activeDeck ? compiled.state.decks[compiled.state.activeDeck]?.outputPath : undefined,
375
+ },
376
+ status: "success",
377
+ summary: `Compiled deck plan from canonical narrative with ${compiled.result.slideCount} slide${compiled.result.slideCount === 1 ? "" : "s"}.`,
378
+ nodeIds: [compiled.state.narrative?.id, compiled.state.activeDeck ? `artifact:${compiled.state.decks[compiled.state.activeDeck]?.outputPath ?? compiled.state.activeDeck}` : undefined].filter((item): item is string => Boolean(item)),
379
+ })
380
+ }
381
+ writeDecksState(workspaceRoot, compiled.state)
382
+ return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result: compiled.result, deck: compiled.state.activeDeck ? compiled.state.decks[compiled.state.activeDeck] : undefined, narrative: compiled.state.narrative }, null, 2)
383
+ }
384
+
385
+ if (args.action === "reviewNarrative") {
386
+ const reviewed = reviewNarrativeState(state)
387
+ recordNarrativeReviewAction(reviewed.state, reviewed.result)
388
+ writeDecksState(workspaceRoot, reviewed.state)
389
+ return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result: reviewed.result, narrative: reviewed.state.narrative }, null, 2)
390
+ }
391
+
392
+ if (args.action === "approveNarrative") {
393
+ const approved = approveNarrativeState(state, {
394
+ approvedBy: args.approvalBy,
395
+ scope: args.approvalScope,
396
+ note: args.approvalNote,
397
+ })
398
+ recordNarrativeApprovalAction(approved.state, approved.result)
399
+ writeDecksState(workspaceRoot, approved.state)
400
+ return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result: approved.result, narrative: approved.state.narrative }, null, 2)
401
+ }
402
+
185
403
  if (args.action === "applyEvidenceCandidates") {
186
404
  const candidateIds = args.candidateIds ?? []
187
405
  if (candidateIds.length === 0) return JSON.stringify({ ok: false, error: "candidateIds are required for applyEvidenceCandidates" })
188
- const applied = applyEvidenceCandidates(state, candidateIds, { workspaceRoot })
189
- writeDecksState(workspaceRoot, applied.state)
190
- return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result: applied.result }, null, 2)
406
+ const result = applyEvidenceBindings(workspaceRoot, candidateIds)
407
+ return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result }, null, 2)
408
+ }
409
+
410
+ if (args.action === "attachResearchFindings") {
411
+ if (!args.findingsFile?.trim()) return JSON.stringify({ ok: false, error: "findingsFile is required for attachResearchFindings" })
412
+ const result = attachResearchFindings(workspaceRoot, {
413
+ findingsFile: args.findingsFile,
414
+ researchAxis: args.researchAxis,
415
+ status: args.researchStatus,
416
+ })
417
+ return JSON.stringify({ ok: true, path: DECKS_STATE_FILE, result }, null, 2)
191
418
  }
192
419
 
193
420
  if (args.action === "remember") {
package/tools/pdf.ts CHANGED
@@ -9,6 +9,7 @@ import { existsSync } from "fs"
9
9
  import { resolve } from "path"
10
10
  import { exportToPdf } from "../lib/pdf/export"
11
11
  import { assertExportQAPassed } from "../lib/qa/export-gate"
12
+ import { recordRenderedArtifact, workspaceRelative } from "../lib/workspace-state/rendered-artifacts"
12
13
 
13
14
  export default tool({
14
15
  description:
@@ -35,8 +36,15 @@ export default tool({
35
36
  }
36
37
 
37
38
  try {
38
- await assertExportQAPassed(filePath)
39
+ const root = directory || process.cwd()
40
+ await assertExportQAPassed(filePath, { workspaceRoot: root })
39
41
  const result = await exportToPdf(filePath)
42
+ recordRenderedArtifact(root, {
43
+ sourceHtmlPath: workspaceRelative(resolve(root), filePath),
44
+ outputPath: result.outputPath,
45
+ type: "pdf",
46
+ actor: "revela-pdf",
47
+ })
40
48
  return JSON.stringify({ ok: true, ...result }, null, 2)
41
49
  } catch (e: any) {
42
50
  return JSON.stringify({ ok: false, error: e?.message ?? String(e) })
package/tools/pptx.ts CHANGED
@@ -7,7 +7,9 @@
7
7
  import { tool } from "@opencode-ai/plugin"
8
8
  import { existsSync } from "fs"
9
9
  import { resolve } from "path"
10
+ import { assertDeckHtmlContractValid } from "../lib/deck-html/contract"
10
11
  import { exportToPptx } from "../lib/pptx/export"
12
+ import { recordRenderedArtifact, workspaceRelative } from "../lib/workspace-state/rendered-artifacts"
11
13
 
12
14
  export default tool({
13
15
  description:
@@ -42,12 +44,20 @@ export default tool({
42
44
  const progress: string[] = []
43
45
 
44
46
  try {
47
+ const root = directory || process.cwd()
48
+ assertDeckHtmlContractValid(root, filePath)
45
49
  const result = await exportToPptx(filePath, {
46
50
  speakerNotes: normalizeSpeakerNotes(speakerNotes),
47
51
  onProgress: (event) => {
48
52
  progress.push(event.message)
49
53
  },
50
54
  })
55
+ recordRenderedArtifact(root, {
56
+ sourceHtmlPath: workspaceRelative(resolve(root), filePath),
57
+ outputPath: result.outputPath,
58
+ type: "pptx",
59
+ actor: "revela-pptx",
60
+ })
51
61
  return JSON.stringify({ ok: true, ...result, progress }, null, 2)
52
62
  } catch (e: any) {
53
63
  return JSON.stringify({ ok: false, error: e?.message ?? String(e), progress })
@@ -1,6 +1,8 @@
1
1
  import { tool } from "@opencode-ai/plugin"
2
2
  import { mkdirSync, writeFileSync } from "fs"
3
3
  import { join } from "path"
4
+ import { hasDecksState, readDecksState, writeDecksState } from "../lib/decks-state"
5
+ import { recordWorkspaceAction } from "../lib/workspace-state/actions"
4
6
 
5
7
  /**
6
8
  * Format today's date as YYYY-MM-DD
@@ -88,6 +90,19 @@ export default tool({
88
90
 
89
91
  writeFileSync(filePath, fileContent, "utf-8")
90
92
 
93
+ if (hasDecksState(workspaceDir)) {
94
+ const state = readDecksState(workspaceDir)
95
+ recordWorkspaceAction(state, {
96
+ type: "research.findings_saved",
97
+ actor: "revela-research-save",
98
+ inputs: { topic: topicKey, axis: fileKey, sourceCount: args.sources?.length ?? 0 },
99
+ outputs: { path: relPath, sources: args.sources ?? [] },
100
+ summary: `Saved research findings for ${topicKey}/${fileKey}.`,
101
+ nodeIds: [`finding:${relPath}`],
102
+ })
103
+ writeDecksState(workspaceDir, state)
104
+ }
105
+
91
106
  return JSON.stringify({ ok: true, path: relPath })
92
107
  } catch (e: any) {
93
108
  return JSON.stringify({ error: e.message || String(e) })
@@ -3,6 +3,8 @@ import { readdirSync, statSync, existsSync } from "fs"
3
3
  import { join, relative, extname, resolve, sep, isAbsolute } from "path"
4
4
  import { sourceMaterialMetadata } from "../lib/source-materials"
5
5
  import type { SourceMaterial } from "../lib/decks-state"
6
+ import { hasDecksState, readDecksState, writeDecksState } from "../lib/decks-state"
7
+ import { recordWorkspaceAction } from "../lib/workspace-state/actions"
6
8
 
7
9
  const DOC_EXTENSIONS = new Set([
8
10
  ".pdf", ".docx", ".doc", ".xlsx", ".xls",
@@ -16,6 +18,17 @@ const EXCLUDE_DIRS = new Set([
16
18
  "designs", "domains", // Exclude revela plugin assets
17
19
  ])
18
20
 
21
+ const EXCLUDE_FILENAMES = new Set([
22
+ "AGENTS.md",
23
+ "DECKS.md",
24
+ "README.md",
25
+ "README.zh-CN.md",
26
+ ])
27
+
28
+ function isExcludedFile(entry: string): boolean {
29
+ return entry.startsWith("~$") || EXCLUDE_FILENAMES.has(entry)
30
+ }
31
+
19
32
  type FileEntry = {
20
33
  path: string
21
34
  type: string
@@ -82,6 +95,8 @@ function scanDir(dir: string, rootDir: string, results: FileEntry[], maxDepth: n
82
95
  if (stat.isDirectory()) {
83
96
  scanDir(fullPath, rootDir, results, maxDepth, depth + 1)
84
97
  } else if (stat.isFile()) {
98
+ if (isExcludedFile(entry)) continue
99
+
85
100
  const ext = extname(entry).toLowerCase()
86
101
  if (DOC_EXTENSIONS.has(ext)) {
87
102
  const sourceMaterial = sourceMaterialMetadata(fullPath, rootDir)
@@ -102,7 +117,7 @@ export default tool({
102
117
  "Scan the current workspace for document and data files that can be used as research input. " +
103
118
  "Returns a structured list of all found files with their type and size. " +
104
119
  "Searches for: PDF, Word (docx/doc), Excel (xlsx/xls), PowerPoint (pptx/ppt), CSV, Markdown, and text files. " +
105
- "Excludes node_modules, .git, dist, and the researches/ output directory. " +
120
+ "Excludes node_modules, .git, dist, the researches/ output directory, project docs, and Office lock files. " +
106
121
  "Use this as the first step before reading workspace documents.",
107
122
  args: {
108
123
  path: tool.schema
@@ -145,6 +160,19 @@ export default tool({
145
160
  const results: FileEntry[] = []
146
161
  scanDir(scanRoot, workspaceDir, results, maxDepth, 0)
147
162
 
163
+ if (hasDecksState(workspaceDir)) {
164
+ const state = readDecksState(workspaceDir)
165
+ recordWorkspaceAction(state, {
166
+ type: "workspace.scanned",
167
+ actor: "revela-workspace-scan",
168
+ inputs: { path: args.path, maxDepth },
169
+ outputs: { found: results.length, paths: results.map((file) => file.path) },
170
+ summary: `Scanned workspace documents and found ${results.length} file${results.length === 1 ? "" : "s"}.`,
171
+ nodeIds: results.map((file) => `source:${file.sourceMaterial.path}`),
172
+ })
173
+ writeDecksState(workspaceDir, state)
174
+ }
175
+
148
176
  if (results.length === 0) {
149
177
  return JSON.stringify({
150
178
  found: 0,