@cyber-dash-tech/revela 0.16.4 → 0.17.1

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 (57) 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 +39 -6
  7. package/lib/commands/research.ts +36 -20
  8. package/lib/commands/review.ts +35 -28
  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/media/download.ts +23 -3
  14. package/lib/media/save.ts +1 -0
  15. package/lib/media/types.ts +1 -0
  16. package/lib/narrative-state/display.ts +74 -4
  17. package/lib/narrative-state/map-html.ts +242 -107
  18. package/lib/narrative-state/render-plan.ts +238 -35
  19. package/lib/narrative-state/research-binding-eval.ts +260 -0
  20. package/lib/narrative-state/research-gaps.ts +2 -88
  21. package/lib/narrative-vault/authoring-contract.ts +127 -0
  22. package/lib/narrative-vault/authoring-guard.ts +122 -0
  23. package/lib/narrative-vault/auto-compile.ts +134 -0
  24. package/lib/narrative-vault/bootstrap.ts +63 -0
  25. package/lib/narrative-vault/cache.ts +14 -0
  26. package/lib/narrative-vault/compile-mirror.ts +45 -0
  27. package/lib/narrative-vault/compile.ts +350 -0
  28. package/lib/narrative-vault/constants.ts +6 -0
  29. package/lib/narrative-vault/diagnostic-report.ts +117 -0
  30. package/lib/narrative-vault/export.ts +71 -0
  31. package/lib/narrative-vault/frontmatter.ts +41 -0
  32. package/lib/narrative-vault/hook-targets.ts +40 -0
  33. package/lib/narrative-vault/index.ts +18 -0
  34. package/lib/narrative-vault/inventory.ts +392 -0
  35. package/lib/narrative-vault/markdown-qa.ts +237 -0
  36. package/lib/narrative-vault/markdown.ts +34 -0
  37. package/lib/narrative-vault/migration.ts +52 -0
  38. package/lib/narrative-vault/mutate.ts +361 -0
  39. package/lib/narrative-vault/paths.ts +19 -0
  40. package/lib/narrative-vault/read.ts +52 -0
  41. package/lib/narrative-vault/relations.ts +32 -0
  42. package/lib/narrative-vault/source-loader.ts +19 -0
  43. package/lib/narrative-vault/timestamp.ts +32 -0
  44. package/lib/narrative-vault/types.ts +44 -0
  45. package/lib/qa/checks.ts +206 -5
  46. package/lib/qa/measure.ts +63 -1
  47. package/lib/refine/server.ts +157 -20
  48. package/lib/source-materials.ts +98 -0
  49. package/lib/tool-result.ts +34 -0
  50. package/package.json +2 -2
  51. package/plugin.ts +60 -22
  52. package/skill/NARRATIVE_SKILL.md +25 -10
  53. package/skill/SKILL.md +6 -1
  54. package/tools/decks.ts +363 -67
  55. package/tools/narrative-view.ts +16 -0
  56. package/tools/research-save.ts +3 -0
  57. package/tools/workspace-scan.ts +1 -0
@@ -0,0 +1,52 @@
1
+ import type { DecksState } from "../decks-state"
2
+ import { hasNarrativeVault } from "./paths"
3
+
4
+ export const VAULT_MIGRATION_PRESERVED_IN_DECKS_JSON = [
5
+ "approvals",
6
+ "renderTargets",
7
+ "reviews",
8
+ "deck specs",
9
+ "artifact coverage",
10
+ "actions",
11
+ "sourceMaterials",
12
+ ]
13
+
14
+ export interface NarrativeVaultMigrationHint {
15
+ available: boolean
16
+ reason: string
17
+ suggestedAction?: "exportNarrativeVault"
18
+ preservedInDecksJson: string[]
19
+ nextActions: string[]
20
+ }
21
+
22
+ export function getNarrativeVaultMigrationHint(workspaceRoot: string, state: DecksState): NarrativeVaultMigrationHint {
23
+ if (hasNarrativeVault(workspaceRoot)) {
24
+ return {
25
+ available: false,
26
+ reason: "revela-narrative/ already exists; Markdown is the canonical narrative source.",
27
+ preservedInDecksJson: VAULT_MIGRATION_PRESERVED_IN_DECKS_JSON,
28
+ nextActions: ["Use targeted vault actions or edit Markdown nodes, then run compileNarrativeVault."],
29
+ }
30
+ }
31
+
32
+ if (state.narrative) {
33
+ return {
34
+ available: true,
35
+ reason: "DECKS.json contains a canonical narrative mirror, but no revela-narrative/ vault exists yet.",
36
+ suggestedAction: "exportNarrativeVault",
37
+ preservedInDecksJson: VAULT_MIGRATION_PRESERVED_IN_DECKS_JSON,
38
+ nextActions: [
39
+ "Run revela-decks action exportNarrativeVault to create editable Markdown narrative files.",
40
+ "Continue keeping approvals, render targets, reviews, artifact coverage, actions, and source material records in DECKS.json.",
41
+ "Review the returned diagnosticReport before making or exporting artifacts.",
42
+ ],
43
+ }
44
+ }
45
+
46
+ return {
47
+ available: false,
48
+ reason: "No canonical narrative is available to export yet.",
49
+ preservedInDecksJson: VAULT_MIGRATION_PRESERVED_IN_DECKS_JSON,
50
+ nextActions: ["Initialize narrative intent first, then export a Markdown vault when stable narrative state exists."],
51
+ }
52
+ }
@@ -0,0 +1,361 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"
2
+ import { dirname, join } from "path"
3
+ import type { AudienceIntent, DecisionIntent, NarrativeClaim, NarrativeClaimRelation, NarrativeClaimRelationType, NarrativeEvidenceBinding, NarrativeObjection, NarrativeResearchGap, NarrativeRisk, NarrativeStateV1, NarrativeThesis } from "../narrative-state/types"
4
+ import { NARRATIVE_VAULT_RELATION_TYPES } from "./constants"
5
+ import { narrativeVaultPath } from "./paths"
6
+ import { readNarrativeVaultDocuments } from "./read"
7
+
8
+ export type UpsertVaultEvidenceInput = Partial<NarrativeEvidenceBinding> & {
9
+ id: string
10
+ claimId: string
11
+ }
12
+
13
+ export type UpdateVaultResearchGapInput = Partial<NarrativeResearchGap> & {
14
+ id: string
15
+ }
16
+
17
+ export type UpsertVaultClaimInput = Partial<NarrativeClaim> & {
18
+ id: string
19
+ relations?: Array<Pick<NarrativeClaimRelation, "relation" | "toClaimId" | "rationale">>
20
+ }
21
+
22
+ export interface UpsertVaultRelationInput {
23
+ id: string
24
+ fromId: string
25
+ toId: string
26
+ relation: NarrativeClaimRelationType
27
+ rationale?: string
28
+ }
29
+
30
+ export type UpsertVaultObjectionInput = Partial<NarrativeObjection> & {
31
+ id: string
32
+ }
33
+
34
+ export type UpsertVaultRiskInput = Partial<NarrativeRisk> & {
35
+ id: string
36
+ }
37
+
38
+ export interface UpdateVaultCoreInput {
39
+ status?: NarrativeStateV1["status"]
40
+ audience?: Partial<AudienceIntent>
41
+ decision?: Partial<DecisionIntent>
42
+ thesis?: Partial<NarrativeThesis>
43
+ }
44
+
45
+ export interface VaultNodeMutationResult {
46
+ ok: boolean
47
+ file?: string
48
+ files?: string[]
49
+ nodeId?: string
50
+ missingFields?: string[]
51
+ error?: string
52
+ skipped?: boolean
53
+ }
54
+
55
+ export function upsertVaultClaimNode(workspaceRoot: string, input: UpsertVaultClaimInput): VaultNodeMutationResult {
56
+ const existingPath = existingNodePath(workspaceRoot, "claim", input.id)
57
+ const existing = existingPath ? readNarrativeVaultDocuments(workspaceRoot).documents.find((doc) => doc.relativePath === existingPath) : undefined
58
+ const missing = existing ? [] : missingNewClaimFields(input)
59
+ if (missing.length > 0) return { ok: false, nodeId: input.id, missingFields: missing, error: `Claim node is missing required fields for creation: ${missing.join(", ")}.` }
60
+
61
+ const root = narrativeVaultPath(workspaceRoot)
62
+ const relativePath = existingPath ?? join("claims", `${safeFileName(input.id)}.md`)
63
+ const frontmatter = {
64
+ ...(existing?.frontmatter ?? {}),
65
+ type: "claim",
66
+ id: input.id,
67
+ kind: input.kind ?? existing?.frontmatter.kind,
68
+ importance: input.importance ?? existing?.frontmatter.importance,
69
+ evidenceRequired: input.evidenceRequired ?? existing?.frontmatter.evidenceRequired,
70
+ supportedScope: input.supportedScope ?? existing?.frontmatter.supportedScope,
71
+ unsupportedScope: input.unsupportedScope ?? existing?.frontmatter.unsupportedScope,
72
+ text: input.text ?? existing?.frontmatter.text,
73
+ caveats: input.caveats ? undefined : existing?.frontmatter.caveats,
74
+ }
75
+ const overrides: Record<string, string> = {}
76
+ if (input.caveats) overrides.caveats = formatList(input.caveats)
77
+ const body = buildNodeBody(input.text ?? (stringValue(existing?.frontmatter.text) || existing?.body.trim() || ""), existing?.sections, overrides)
78
+ writeVaultNode(root, relativePath, frontmatter, body)
79
+ return { ok: true, file: relativePath, nodeId: input.id }
80
+ }
81
+
82
+ export function upsertVaultRelation(_workspaceRoot: string, input: UpsertVaultRelationInput): VaultNodeMutationResult {
83
+ const missing = missingRelationFields(input)
84
+ if (missing.length > 0) return { ok: false, nodeId: input.id, missingFields: missing, error: `Relation edge is missing required fields: ${missing.join(", ")}.` }
85
+ if (!isRelationType(input.relation)) return { ok: false, nodeId: input.id, error: `Invalid relation type: ${input.relation}.` }
86
+ return { ok: false, skipped: true, nodeId: input.id, error: "Relation registry helpers are disabled. Add or edit the source node's ## Relations wikilink instead; compileNarrativeVault will generate the relation id." }
87
+ }
88
+
89
+ export function removeVaultRelation(_workspaceRoot: string, id: string): VaultNodeMutationResult {
90
+ const edgeId = id.trim()
91
+ if (!edgeId) return { ok: false, missingFields: ["id"], error: "Relation id is required for removal." }
92
+ return { ok: false, skipped: true, nodeId: edgeId, error: "Relation registry helpers are disabled. Remove the matching line from the source node's ## Relations section instead." }
93
+ }
94
+
95
+ export function upsertVaultObjectionNode(workspaceRoot: string, input: UpsertVaultObjectionInput): VaultNodeMutationResult {
96
+ const existingPath = existingNodePath(workspaceRoot, "objection", input.id)
97
+ const existing = existingPath ? readNarrativeVaultDocuments(workspaceRoot).documents.find((doc) => doc.relativePath === existingPath) : undefined
98
+ const missing = existing ? [] : missingNewTextNodeFields(input)
99
+ if (missing.length > 0) return { ok: false, nodeId: input.id, missingFields: missing, error: `Objection node is missing required fields for creation: ${missing.join(", ")}.` }
100
+
101
+ const root = narrativeVaultPath(workspaceRoot)
102
+ const relativePath = existingPath ?? join("objections", `${safeFileName(input.id)}.md`)
103
+ const frontmatter = {
104
+ ...(existing?.frontmatter ?? {}),
105
+ type: "objection",
106
+ id: input.id,
107
+ text: input.text ?? existing?.frontmatter.text,
108
+ claimId: input.claimId ?? existing?.frontmatter.claimId,
109
+ priority: input.priority ?? existing?.frontmatter.priority,
110
+ response: input.response ?? existing?.frontmatter.response,
111
+ }
112
+ const overrides: Record<string, string> = input.response ? { response: input.response } : {}
113
+ const claimId = input.claimId ?? stringValue(existing?.frontmatter.claimId)
114
+ if (claimId) overrides.relations = relationLines([{ relation: "answers", targetId: claimId }])
115
+ const body = buildNodeBody(input.text ?? (stringValue(existing?.frontmatter.text) || existing?.body.trim() || ""), existing?.sections, overrides)
116
+ writeVaultNode(root, relativePath, frontmatter, body)
117
+ return { ok: true, file: relativePath, nodeId: input.id }
118
+ }
119
+
120
+ export function upsertVaultRiskNode(workspaceRoot: string, input: UpsertVaultRiskInput): VaultNodeMutationResult {
121
+ const existingPath = existingNodePath(workspaceRoot, "risk", input.id)
122
+ const existing = existingPath ? readNarrativeVaultDocuments(workspaceRoot).documents.find((doc) => doc.relativePath === existingPath) : undefined
123
+ const missing = existing ? [] : missingNewTextNodeFields(input)
124
+ if (missing.length > 0) return { ok: false, nodeId: input.id, missingFields: missing, error: `Risk node is missing required fields for creation: ${missing.join(", ")}.` }
125
+
126
+ const root = narrativeVaultPath(workspaceRoot)
127
+ const relativePath = existingPath ?? join("risks", `${safeFileName(input.id)}.md`)
128
+ const frontmatter = {
129
+ ...(existing?.frontmatter ?? {}),
130
+ type: "risk",
131
+ id: input.id,
132
+ text: input.text ?? existing?.frontmatter.text,
133
+ claimId: input.claimId ?? existing?.frontmatter.claimId,
134
+ severity: input.severity ?? existing?.frontmatter.severity,
135
+ mitigation: input.mitigation ?? existing?.frontmatter.mitigation,
136
+ }
137
+ const overrides: Record<string, string> = input.mitigation ? { mitigation: input.mitigation } : {}
138
+ const claimId = input.claimId ?? stringValue(existing?.frontmatter.claimId)
139
+ if (claimId) overrides.relations = relationLines([{ relation: "constrains", targetId: claimId }])
140
+ const body = buildNodeBody(input.text ?? (stringValue(existing?.frontmatter.text) || existing?.body.trim() || ""), existing?.sections, overrides)
141
+ writeVaultNode(root, relativePath, frontmatter, body)
142
+ return { ok: true, file: relativePath, nodeId: input.id }
143
+ }
144
+
145
+ export function updateVaultCoreNodes(workspaceRoot: string, input: UpdateVaultCoreInput): VaultNodeMutationResult {
146
+ const root = narrativeVaultPath(workspaceRoot)
147
+ const read = readNarrativeVaultDocuments(workspaceRoot)
148
+ const files: string[] = []
149
+ if (input.status) {
150
+ const existing = read.documents.find((doc) => doc.relativePath === "index.md")
151
+ writeVaultNode(root, "index.md", { ...(existing?.frontmatter ?? {}), type: "index", id: stringValue(existing?.frontmatter.id) || "narrative:vault", status: input.status }, existing?.body ? `${existing.body.trim()}\n` : "")
152
+ files.push("index.md")
153
+ }
154
+ if (input.audience) {
155
+ const existing = read.documents.find((doc) => doc.relativePath === "audience.md")
156
+ const frontmatter = { ...(existing?.frontmatter ?? {}), type: "audience", ...input.audience }
157
+ writeVaultNode(root, "audience.md", frontmatter, `${input.audience.primary ?? existing?.body.trim() ?? ""}\n`)
158
+ files.push("audience.md")
159
+ }
160
+ if (input.decision) {
161
+ const existing = read.documents.find((doc) => doc.relativePath === "decision.md")
162
+ const frontmatter = { ...(existing?.frontmatter ?? {}), type: "decision", ...input.decision }
163
+ writeVaultNode(root, "decision.md", frontmatter, `${input.decision.action ?? existing?.body.trim() ?? ""}\n`)
164
+ files.push("decision.md")
165
+ }
166
+ if (input.thesis) {
167
+ const existing = read.documents.find((doc) => doc.relativePath === "thesis.md")
168
+ const frontmatter = { ...(existing?.frontmatter ?? {}), type: "thesis", id: input.thesis.id ?? existing?.frontmatter.id ?? "thesis:main", confidence: input.thesis.confidence ?? existing?.frontmatter.confidence, caveat: input.thesis.caveat ?? existing?.frontmatter.caveat }
169
+ writeVaultNode(root, "thesis.md", frontmatter, `${input.thesis.statement ?? existing?.body.trim() ?? ""}\n`)
170
+ files.push("thesis.md")
171
+ }
172
+ if (files.length === 0) return { ok: false, missingFields: ["status|audience|decision|thesis"], error: "No core narrative fields were provided." }
173
+ return { ok: true, file: files[0], files, nodeId: "narrative:core" }
174
+ }
175
+
176
+ export function upsertVaultEvidenceNode(workspaceRoot: string, input: UpsertVaultEvidenceInput): VaultNodeMutationResult {
177
+ const missing = missingEvidenceFields(input)
178
+ if (missing.length > 0) return { ok: false, nodeId: input.id, missingFields: missing, error: `Evidence node is missing required source trace fields: ${missing.join(", ")}.` }
179
+
180
+ const root = narrativeVaultPath(workspaceRoot)
181
+ const relativePath = existingNodePath(workspaceRoot, "evidence", input.id) ?? join("evidence", `${safeFileName(input.id)}.md`)
182
+ const existing = readNarrativeVaultDocuments(workspaceRoot).documents.find((doc) => doc.relativePath === relativePath)
183
+ const frontmatter = {
184
+ ...(existing?.frontmatter ?? {}),
185
+ type: "evidence",
186
+ id: input.id,
187
+ claimId: input.claimId,
188
+ source: input.source,
189
+ sourcePath: input.sourcePath,
190
+ findingsFile: input.findingsFile,
191
+ quote: input.quote,
192
+ location: input.location,
193
+ url: input.url,
194
+ caveat: input.caveat,
195
+ supportScope: input.supportScope,
196
+ unsupportedScope: input.unsupportedScope,
197
+ strength: input.strength,
198
+ }
199
+ const body = buildNodeBody(input.quote?.trim() ?? existing?.body.trim() ?? "", existing?.sections, { relations: relationLines([{ relation: "supports", targetId: input.claimId }]) })
200
+ writeVaultNode(root, relativePath, frontmatter, body)
201
+ return { ok: true, file: relativePath, nodeId: input.id }
202
+ }
203
+
204
+ export function updateVaultResearchGapNode(workspaceRoot: string, input: UpdateVaultResearchGapInput, options: { now?: string } = {}): VaultNodeMutationResult {
205
+ const now = options.now ?? new Date().toISOString()
206
+ const existingPath = existingNodePath(workspaceRoot, "research-gap", input.id)
207
+ const existing = existingPath ? readNarrativeVaultDocuments(workspaceRoot).documents.find((doc) => doc.relativePath === existingPath) : undefined
208
+ const missing = existing ? [] : missingNewResearchGapFields(input)
209
+ if (missing.length > 0) return { ok: false, nodeId: input.id, missingFields: missing, error: `Research gap node is missing required fields for creation: ${missing.join(", ")}.` }
210
+
211
+ const root = narrativeVaultPath(workspaceRoot)
212
+ const relativePath = existingPath ?? join("research-gaps", `${safeFileName(input.id)}.md`)
213
+ const currentStatus = stringValue(existing?.frontmatter.status)
214
+ const nextStatus = input.status ?? (currentStatus || "open")
215
+ const frontmatter = {
216
+ ...(existing?.frontmatter ?? {}),
217
+ type: "research-gap",
218
+ id: input.id,
219
+ targetType: input.targetType ?? existing?.frontmatter.targetType,
220
+ targetId: input.targetId ?? existing?.frontmatter.targetId,
221
+ question: input.question ?? existing?.frontmatter.question,
222
+ status: nextStatus,
223
+ priority: input.priority ?? existing?.frontmatter.priority,
224
+ findingsFile: input.findingsFile ?? existing?.frontmatter.findingsFile,
225
+ evidenceBindingIds: input.evidenceBindingIds ?? existing?.frontmatter.evidenceBindingIds,
226
+ createdFromIssueType: input.createdFromIssueType ?? existing?.frontmatter.createdFromIssueType,
227
+ notes: input.notes ?? existing?.frontmatter.notes,
228
+ createdAt: existing?.frontmatter.createdAt ?? input.createdAt ?? now,
229
+ updatedAt: now,
230
+ closedAt: nextStatus === "closed" ? input.closedAt ?? existing?.frontmatter.closedAt ?? now : input.closedAt ?? existing?.frontmatter.closedAt,
231
+ }
232
+ const question = input.question ?? (stringValue(existing?.frontmatter.question) || existing?.body.trim() || "")
233
+ const notes = input.notes ?? (stringValue(existing?.frontmatter.notes) || existing?.sections.notes?.trim() || "")
234
+ const relationTargets = [
235
+ ...(input.evidenceBindingIds ?? arrayValue(existing?.frontmatter.evidenceBindingIds)).map((targetId) => ({ relation: "depends_on" as const, targetId })),
236
+ ...((input.targetId ?? stringValue(existing?.frontmatter.targetId)) ? [{ relation: "depends_on" as const, targetId: input.targetId ?? stringValue(existing?.frontmatter.targetId) }] : []),
237
+ ]
238
+ const body = buildNodeBody(question, existing?.sections, {
239
+ ...(notes ? { notes } : {}),
240
+ ...(relationTargets.length > 0 ? { relations: relationLines(relationTargets) } : {}),
241
+ })
242
+ writeVaultNode(root, relativePath, frontmatter, body)
243
+ return { ok: true, file: relativePath, nodeId: input.id }
244
+ }
245
+
246
+ function missingEvidenceFields(input: UpsertVaultEvidenceInput): string[] {
247
+ const missing: string[] = []
248
+ for (const key of ["id", "claimId", "source", "quote", "supportScope", "unsupportedScope", "caveat", "strength"] as const) {
249
+ if (!String(input[key] ?? "").trim()) missing.push(key)
250
+ }
251
+ if (!input.sourcePath?.trim() && !input.url?.trim() && !input.findingsFile?.trim()) missing.push("sourcePath|url|findingsFile")
252
+ return missing
253
+ }
254
+
255
+ function missingRelationFields(input: Partial<UpsertVaultRelationInput>): string[] {
256
+ const missing: string[] = []
257
+ for (const key of ["id", "fromId", "toId", "relation"] as const) {
258
+ if (!String(input[key] ?? "").trim()) missing.push(key)
259
+ }
260
+ return missing
261
+ }
262
+
263
+ function isRelationType(value: string): value is NarrativeClaimRelationType {
264
+ return (NARRATIVE_VAULT_RELATION_TYPES as readonly string[]).includes(value)
265
+ }
266
+
267
+ function missingNewResearchGapFields(input: UpdateVaultResearchGapInput): string[] {
268
+ const missing: string[] = []
269
+ for (const key of ["id", "targetType", "targetId", "question"] as const) {
270
+ if (!String(input[key] ?? "").trim()) missing.push(key)
271
+ }
272
+ return missing
273
+ }
274
+
275
+ function missingNewClaimFields(input: UpsertVaultClaimInput): string[] {
276
+ const missing = missingNewTextNodeFields(input)
277
+ for (const key of ["kind", "importance", "evidenceRequired"] as const) {
278
+ if (input[key] === undefined || !String(input[key]).trim()) missing.push(key)
279
+ }
280
+ return missing
281
+ }
282
+
283
+ function missingNewTextNodeFields(input: { id?: string; text?: string }): string[] {
284
+ const missing: string[] = []
285
+ for (const key of ["id", "text"] as const) {
286
+ if (!String(input[key] ?? "").trim()) missing.push(key)
287
+ }
288
+ return missing
289
+ }
290
+
291
+ function existingNodePath(workspaceRoot: string, type: string, id: string): string | undefined {
292
+ if (!existsSync(narrativeVaultPath(workspaceRoot))) return undefined
293
+ return readNarrativeVaultDocuments(workspaceRoot).documents.find((doc) => doc.frontmatter.type === type && doc.frontmatter.id === id)?.relativePath
294
+ }
295
+
296
+ function writeVaultNode(root: string, relativePath: string, frontmatter: Record<string, unknown>, body: string): void {
297
+ const filePath = join(root, relativePath)
298
+ mkdirSync(dirname(filePath), { recursive: true })
299
+ writeFileSync(filePath, `${formatFrontmatter(frontmatter)}\n${body.endsWith("\n") ? body : `${body}\n`}`, "utf-8")
300
+ }
301
+
302
+ function formatFrontmatter(values: Record<string, unknown>): string {
303
+ const lines = ["---"]
304
+ for (const [key, value] of Object.entries(values)) {
305
+ if (value === undefined || value === "" || (Array.isArray(value) && value.length === 0)) continue
306
+ if (Array.isArray(value)) {
307
+ lines.push(`${key}:`)
308
+ for (const item of value) lines.push(` - ${quote(String(item))}`)
309
+ } else if (typeof value === "boolean") {
310
+ lines.push(`${key}: ${value ? "true" : "false"}`)
311
+ } else {
312
+ lines.push(`${key}: ${quote(String(value))}`)
313
+ }
314
+ }
315
+ lines.push("---")
316
+ return lines.join("\n")
317
+ }
318
+
319
+ function buildNodeBody(main: string, existingSections: Record<string, string> = {}, overrides: Record<string, string> = {}): string {
320
+ const sections = { ...existingSections, ...overrides }
321
+ const chunks = [main.trim()]
322
+ for (const [name, value] of Object.entries(sections)) {
323
+ const trimmed = value.trim()
324
+ if (!trimmed) continue
325
+ chunks.push(`## ${sectionTitle(name)}\n\n${trimmed}`)
326
+ }
327
+ return `${chunks.filter(Boolean).join("\n\n")}\n`
328
+ }
329
+
330
+ function relationLines(items: Array<{ relation: NarrativeClaimRelationType; targetId: string }>): string {
331
+ return items
332
+ .filter((item) => item.targetId.trim())
333
+ .map((item) => `- ${item.relation}: [[${item.targetId.trim()}]]`)
334
+ .join("\n")
335
+ }
336
+
337
+ function arrayValue(value: unknown): string[] {
338
+ if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean)
339
+ if (typeof value === "string" && value.trim()) return value.split(",").map((item) => item.trim()).filter(Boolean)
340
+ return []
341
+ }
342
+
343
+ function formatList(items: string[]): string {
344
+ return items.map((item) => `- ${item}`).join("\n")
345
+ }
346
+
347
+ function sectionTitle(name: string): string {
348
+ return name.split("-").map((part) => part ? `${part[0].toUpperCase()}${part.slice(1)}` : part).join(" ")
349
+ }
350
+
351
+ function quote(value: string): string {
352
+ return JSON.stringify(value)
353
+ }
354
+
355
+ function safeFileName(id: string): string {
356
+ return id.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "node"
357
+ }
358
+
359
+ function stringValue(value: unknown): string {
360
+ return typeof value === "string" ? value.trim() : ""
361
+ }
@@ -0,0 +1,19 @@
1
+ import { existsSync } from "fs"
2
+ import { join } from "path"
3
+ import { NARRATIVE_VAULT_CACHE_DIR, NARRATIVE_VAULT_DIR } from "./constants"
4
+
5
+ export function narrativeVaultPath(workspaceRoot: string): string {
6
+ return join(workspaceRoot, NARRATIVE_VAULT_DIR)
7
+ }
8
+
9
+ export function narrativeVaultCachePath(workspaceRoot: string): string {
10
+ return join(workspaceRoot, NARRATIVE_VAULT_CACHE_DIR)
11
+ }
12
+
13
+ export function hasNarrativeVault(workspaceRoot: string): boolean {
14
+ return existsSync(narrativeVaultPath(workspaceRoot))
15
+ }
16
+
17
+ export function vaultRelativePath(filePath: string): string {
18
+ return filePath.split(/[/\\]+/).join("/")
19
+ }
@@ -0,0 +1,52 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs"
2
+ import { join, relative } from "path"
3
+ import { NARRATIVE_VAULT_NODE_DIRS } from "./constants"
4
+ import { parseVaultFrontmatter } from "./frontmatter"
5
+ import { splitMarkdownSections } from "./markdown"
6
+ import { narrativeVaultPath, vaultRelativePath } from "./paths"
7
+ import { parseRelations } from "./relations"
8
+ import type { VaultDiagnostic, VaultDocument } from "./types"
9
+
10
+ export function listNarrativeVaultFiles(workspaceRoot: string): string[] {
11
+ const root = narrativeVaultPath(workspaceRoot)
12
+ if (!existsSync(root)) return []
13
+ const files = ["index.md", "audience.md", "decision.md", "thesis.md"].map((file) => join(root, file)).filter((file) => existsSync(file))
14
+ for (const dir of NARRATIVE_VAULT_NODE_DIRS) {
15
+ const folder = join(root, dir)
16
+ if (!existsSync(folder) || !statSync(folder).isDirectory()) continue
17
+ for (const entry of readdirSync(folder).sort()) {
18
+ const file = join(folder, entry)
19
+ if (entry.endsWith(".md") && statSync(file).isFile()) files.push(file)
20
+ }
21
+ }
22
+ return files
23
+ }
24
+
25
+ export function readNarrativeVaultDocuments(workspaceRoot: string): { documents: VaultDocument[]; diagnostics: VaultDiagnostic[] } {
26
+ const root = narrativeVaultPath(workspaceRoot)
27
+ const diagnostics: VaultDiagnostic[] = []
28
+ const documents = listNarrativeVaultFiles(workspaceRoot).map((path) => {
29
+ const markdown = readFileSync(path, "utf-8")
30
+ const parsed = parseVaultFrontmatter(markdown)
31
+ const split = splitMarkdownSections(parsed.body)
32
+ const id = stringField(parsed.frontmatter, "id")
33
+ const relationResult = id ? parseRelations(split.sections.relations ?? "", id, vaultRelativePath(relative(root, path))) : { relations: [], unknownTypes: [] }
34
+ for (const type of relationResult.unknownTypes) {
35
+ diagnostics.push({ severity: "error", code: "unknown_relation_type", message: `Unknown relation type: ${type}`, file: vaultRelativePath(relative(root, path)), nodeId: id })
36
+ }
37
+ return {
38
+ path,
39
+ relativePath: vaultRelativePath(relative(root, path)),
40
+ frontmatter: parsed.frontmatter,
41
+ body: split.main,
42
+ sections: split.sections,
43
+ relations: relationResult.relations,
44
+ }
45
+ })
46
+ return { documents, diagnostics }
47
+ }
48
+
49
+ function stringField(frontmatter: Record<string, string | string[] | boolean>, key: string): string {
50
+ const value = frontmatter[key]
51
+ return typeof value === "string" ? value.trim() : ""
52
+ }
@@ -0,0 +1,32 @@
1
+ import { NARRATIVE_VAULT_RELATION_TYPES } from "./constants"
2
+ import type { VaultRelation } from "./types"
3
+ import type { NarrativeClaimRelationType } from "../narrative-state/types"
4
+
5
+ export function parseRelations(section: string, fromId: string, file: string): { relations: VaultRelation[]; unknownTypes: string[] } {
6
+ const relations: VaultRelation[] = []
7
+ const unknownTypes: string[] = []
8
+ for (const line of section.split("\n")) {
9
+ const match = /^\s*-\s*([a-z_]+):\s*\[\[([^\]|]+)(?:\|[^\]]+)?\]\](?:\s*-\s*(.+))?\s*$/.exec(line)
10
+ if (!match) continue
11
+ const relation = match[1]
12
+ if (!isRelationType(relation)) {
13
+ unknownTypes.push(relation)
14
+ continue
15
+ }
16
+ const toId = match[2].trim()
17
+ relations.push({ id: stableVaultRelationId(fromId, relation, toId), fromId, relation, toId, rationale: match[3]?.trim(), file, source: "inline" })
18
+ }
19
+ return { relations, unknownTypes }
20
+ }
21
+
22
+ export function stableVaultRelationId(fromId: string, relation: string, toId: string): string {
23
+ return `rel-${slugId(fromId)}-${relation}-${slugId(toId)}`
24
+ }
25
+
26
+ function isRelationType(value: string): value is NarrativeClaimRelationType {
27
+ return (NARRATIVE_VAULT_RELATION_TYPES as readonly string[]).includes(value)
28
+ }
29
+
30
+ function slugId(value: string): string {
31
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "node"
32
+ }
@@ -0,0 +1,19 @@
1
+ import type { NarrativeApproval, NarrativeStateV1 } from "../narrative-state/types"
2
+ import { writeNarrativeVaultCache } from "./cache"
3
+ import { compileNarrativeVault } from "./compile"
4
+ import { hasNarrativeVault } from "./paths"
5
+ import type { NarrativeVaultCompileResult } from "./types"
6
+
7
+ export interface PreferredNarrativeLoadResult {
8
+ source: "vault" | "state"
9
+ narrative?: NarrativeStateV1
10
+ compileResult?: NarrativeVaultCompileResult
11
+ }
12
+
13
+ export function loadNarrativeFromPreferredSource(workspaceRoot: string, stateNarrative: NarrativeStateV1 | undefined, fallbackApprovals?: NarrativeApproval[]): PreferredNarrativeLoadResult {
14
+ if (!hasNarrativeVault(workspaceRoot)) return { source: "state", narrative: stateNarrative }
15
+ const approvals: NarrativeApproval[] = fallbackApprovals ?? stateNarrative?.approvals ?? []
16
+ const compileResult = compileNarrativeVault(workspaceRoot, { fallbackApprovals: approvals })
17
+ writeNarrativeVaultCache(workspaceRoot, compileResult)
18
+ return { source: "vault", narrative: compileResult.narrative, compileResult }
19
+ }
@@ -0,0 +1,32 @@
1
+ import { existsSync, readdirSync, statSync } from "fs"
2
+ import { join } from "path"
3
+ import { narrativeVaultPath } from "./paths"
4
+
5
+ export function narrativeVaultTimestampMs(workspaceRoot: string): number {
6
+ const root = narrativeVaultPath(workspaceRoot)
7
+ if (!existsSync(root)) return 0
8
+ return newestMarkdownMtime(root)
9
+ }
10
+
11
+ function newestMarkdownMtime(dir: string): number {
12
+ let newest = 0
13
+ let entries: string[]
14
+ try {
15
+ entries = readdirSync(dir)
16
+ } catch {
17
+ return 0
18
+ }
19
+
20
+ for (const entry of entries) {
21
+ const path = join(dir, entry)
22
+ let stat
23
+ try {
24
+ stat = statSync(path)
25
+ } catch {
26
+ continue
27
+ }
28
+ if (stat.isDirectory()) newest = Math.max(newest, newestMarkdownMtime(path))
29
+ else if (stat.isFile() && entry.endsWith(".md")) newest = Math.max(newest, stat.mtimeMs)
30
+ }
31
+ return newest
32
+ }
@@ -0,0 +1,44 @@
1
+ import type { NarrativeClaimRelationType, NarrativeStateV1 } from "../narrative-state/types"
2
+
3
+ export type VaultNodeType = "index" | "audience" | "decision" | "thesis" | "claim" | "evidence" | "objection" | "risk" | "research-gap"
4
+
5
+ export type VaultDiagnosticSeverity = "error" | "warning"
6
+
7
+ export interface VaultDiagnostic {
8
+ severity: VaultDiagnosticSeverity
9
+ code: string
10
+ message: string
11
+ file?: string
12
+ nodeId?: string
13
+ }
14
+
15
+ export interface VaultRelation {
16
+ id?: string
17
+ fromId: string
18
+ relation: NarrativeClaimRelationType
19
+ toId: string
20
+ rationale?: string
21
+ file: string
22
+ source?: "inline"
23
+ }
24
+
25
+ export interface VaultDocument {
26
+ path: string
27
+ relativePath: string
28
+ frontmatter: Record<string, string | string[] | boolean>
29
+ body: string
30
+ sections: Record<string, string>
31
+ relations: VaultRelation[]
32
+ }
33
+
34
+ export interface NarrativeVaultCompileResult {
35
+ ok: boolean
36
+ narrative?: NarrativeStateV1
37
+ diagnostics: VaultDiagnostic[]
38
+ graph: NarrativeVaultGraph
39
+ }
40
+
41
+ export interface NarrativeVaultGraph {
42
+ nodes: Array<{ id: string; type: VaultNodeType; file: string }>
43
+ relations: VaultRelation[]
44
+ }