@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,392 @@
1
+ import { readNarrativeVaultDocuments } from "./read"
2
+ import { stableVaultRelationId } from "./relations"
3
+ import type { NarrativeClaimRelationType } from "../narrative-state/types"
4
+ import type { VaultDiagnostic, VaultDocument, VaultNodeType, VaultRelation } from "./types"
5
+
6
+ export interface NarrativeVaultInventoryNode {
7
+ id: string
8
+ type: VaultNodeType
9
+ file: string
10
+ title: string
11
+ text: string
12
+ }
13
+
14
+ export interface NarrativeVaultInventoryClaim extends NarrativeVaultInventoryNode {
15
+ kind: string
16
+ importance: string
17
+ evidenceRequired: boolean
18
+ evidenceStatus: string
19
+ }
20
+
21
+ export interface NarrativeVaultInventoryEvidence extends NarrativeVaultInventoryNode {
22
+ claimId: string
23
+ source: string
24
+ sourcePath: string
25
+ url: string
26
+ findingsFile: string
27
+ strength: string
28
+ quote: string
29
+ }
30
+
31
+ export interface NarrativeVaultInventoryResearchGap extends NarrativeVaultInventoryNode {
32
+ targetType: string
33
+ targetId: string
34
+ question: string
35
+ status: string
36
+ priority: string
37
+ findingsFile: string
38
+ }
39
+
40
+ export interface NarrativeVaultInventoryObjection extends NarrativeVaultInventoryNode {
41
+ claimId: string
42
+ priority: string
43
+ }
44
+
45
+ export interface NarrativeVaultInventoryRisk extends NarrativeVaultInventoryNode {
46
+ claimId: string
47
+ severity: string
48
+ }
49
+
50
+ export interface NarrativeVaultInventoryRelation extends VaultRelation {
51
+ unresolved: boolean
52
+ }
53
+
54
+ export interface NarrativeVaultRelationCandidate {
55
+ id: string
56
+ fromId: string
57
+ toId: string
58
+ relation: NarrativeClaimRelationType
59
+ rationale: string
60
+ source: "frontmatter"
61
+ }
62
+
63
+ export interface NarrativeVaultRelationCoverage {
64
+ danglingEdges: NarrativeVaultInventoryRelation[]
65
+ unboundEvidence: string[]
66
+ unboundObjections: string[]
67
+ unboundRisks: string[]
68
+ unboundResearchGaps: string[]
69
+ fallbackOnlyBindings: Array<{ nodeId: string; file: string; field: string; relation: NarrativeClaimRelationType; targetId: string }>
70
+ isolatedClaims: string[]
71
+ orphanNodes: string[]
72
+ inlineRelations: Array<{ file: string; nodeId: string }>
73
+ }
74
+
75
+ export interface NarrativeVaultInventoryUnresolvedRef {
76
+ kind: "relation" | "evidenceClaimId" | "gapTarget" | "objectionClaimId" | "riskClaimId"
77
+ fromId: string
78
+ targetId: string
79
+ file: string
80
+ field?: string
81
+ }
82
+
83
+ export interface NarrativeVaultInventory {
84
+ ok: boolean
85
+ path: "revela-narrative"
86
+ counts: {
87
+ claims: number
88
+ evidence: number
89
+ researchGaps: number
90
+ objections: number
91
+ risks: number
92
+ relations: number
93
+ unresolvedRefs: number
94
+ }
95
+ claims: NarrativeVaultInventoryClaim[]
96
+ evidence: NarrativeVaultInventoryEvidence[]
97
+ researchGaps: NarrativeVaultInventoryResearchGap[]
98
+ objections: NarrativeVaultInventoryObjection[]
99
+ risks: NarrativeVaultInventoryRisk[]
100
+ relations: NarrativeVaultInventoryRelation[]
101
+ relationCoverage: NarrativeVaultRelationCoverage
102
+ relationSummary: {
103
+ inlineEdges: number
104
+ advisoryCandidates: number
105
+ }
106
+ relationCandidates: NarrativeVaultRelationCandidate[]
107
+ unresolvedRefs: NarrativeVaultInventoryUnresolvedRef[]
108
+ idHints: {
109
+ nextClaimIdExamples: string[]
110
+ nextEvidenceIdExamples: string[]
111
+ nextResearchGapIdExamples: string[]
112
+ }
113
+ diagnostics: VaultDiagnostic[]
114
+ }
115
+
116
+ export function buildNarrativeVaultInventory(workspaceRoot: string): NarrativeVaultInventory {
117
+ const { documents, diagnostics } = readNarrativeVaultDocuments(workspaceRoot)
118
+ const ids = new Set(documents.map((doc) => nodeId(doc)).filter(Boolean))
119
+ const claimIds = new Set(documents.filter((doc) => nodeType(doc) === "claim").map((doc) => nodeId(doc)).filter(Boolean))
120
+ const unresolvedRefs: NarrativeVaultInventoryUnresolvedRef[] = []
121
+ const relations: NarrativeVaultInventoryRelation[] = []
122
+
123
+ for (const doc of documents) {
124
+ const fromId = nodeId(doc)
125
+ for (const relation of doc.relations) {
126
+ const unresolved = !ids.has(relation.toId)
127
+ relations.push({ ...relation, unresolved })
128
+ if (unresolved) unresolvedRefs.push({ kind: "relation", fromId, targetId: relation.toId, file: relation.file })
129
+ }
130
+ }
131
+
132
+ const claims: NarrativeVaultInventoryClaim[] = []
133
+ const evidence: NarrativeVaultInventoryEvidence[] = []
134
+ const researchGaps: NarrativeVaultInventoryResearchGap[] = []
135
+ const objections: NarrativeVaultInventoryObjection[] = []
136
+ const risks: NarrativeVaultInventoryRisk[] = []
137
+
138
+ for (const doc of documents) {
139
+ const id = nodeId(doc)
140
+ const type = nodeType(doc)
141
+ if (!id || !type) continue
142
+ const base = baseNode(doc, id, type)
143
+ if (type === "claim") {
144
+ claims.push({
145
+ ...base,
146
+ kind: stringField(doc, "kind"),
147
+ importance: stringField(doc, "importance"),
148
+ evidenceRequired: booleanField(doc, "evidenceRequired"),
149
+ evidenceStatus: stringField(doc, "evidenceStatus"),
150
+ })
151
+ continue
152
+ }
153
+ if (type === "evidence") {
154
+ const claimId = stringField(doc, "claimId")
155
+ const inlineClaimId = relationTargetFor(relations, id, "supports", claimIds)
156
+ if (!claimId && !inlineClaimId) unresolvedRefs.push({ kind: "evidenceClaimId", fromId: id, targetId: claimId, file: doc.relativePath, field: "claimId" })
157
+ else if (claimId && !claimIds.has(claimId)) unresolvedRefs.push({ kind: "evidenceClaimId", fromId: id, targetId: claimId, file: doc.relativePath, field: "claimId" })
158
+ evidence.push({
159
+ ...base,
160
+ claimId: inlineClaimId || claimId,
161
+ source: stringField(doc, "source"),
162
+ sourcePath: stringField(doc, "sourcePath"),
163
+ url: stringField(doc, "url"),
164
+ findingsFile: stringField(doc, "findingsFile"),
165
+ strength: stringField(doc, "strength"),
166
+ quote: firstText(stringField(doc, "quote") || doc.body),
167
+ })
168
+ continue
169
+ }
170
+ if (type === "research-gap") {
171
+ const targetType = stringField(doc, "targetType")
172
+ const targetId = stringField(doc, "targetId")
173
+ const inlineTargetId = relationTargetFor(relations, id, "depends_on", ids)
174
+ if (targetId && !isLooseTargetType(targetType) && !ids.has(targetId)) unresolvedRefs.push({ kind: "gapTarget", fromId: id, targetId, file: doc.relativePath, field: "targetId" })
175
+ researchGaps.push({
176
+ ...base,
177
+ targetType,
178
+ targetId: inlineTargetId || targetId,
179
+ question: stringField(doc, "question") || firstText(doc.body),
180
+ status: stringField(doc, "status"),
181
+ priority: stringField(doc, "priority"),
182
+ findingsFile: stringField(doc, "findingsFile"),
183
+ })
184
+ continue
185
+ }
186
+ if (type === "objection") {
187
+ const claimId = stringField(doc, "claimId")
188
+ const inlineClaimId = relationTargetFor(relations, id, "answers", claimIds) || relationTargetFor(relations, id, "contrasts_with", claimIds)
189
+ if (!claimId && !inlineClaimId) unresolvedRefs.push({ kind: "objectionClaimId", fromId: id, targetId: claimId, file: doc.relativePath, field: "claimId" })
190
+ else if (claimId && !claimIds.has(claimId)) unresolvedRefs.push({ kind: "objectionClaimId", fromId: id, targetId: claimId, file: doc.relativePath, field: "claimId" })
191
+ objections.push({ ...base, claimId: inlineClaimId || claimId, priority: stringField(doc, "priority") })
192
+ continue
193
+ }
194
+ if (type === "risk") {
195
+ const claimId = stringField(doc, "claimId")
196
+ const inlineClaimId = relationTargetFor(relations, id, "constrains", claimIds)
197
+ if (!claimId && !inlineClaimId) unresolvedRefs.push({ kind: "riskClaimId", fromId: id, targetId: claimId, file: doc.relativePath, field: "claimId" })
198
+ else if (claimId && !claimIds.has(claimId)) unresolvedRefs.push({ kind: "riskClaimId", fromId: id, targetId: claimId, file: doc.relativePath, field: "claimId" })
199
+ risks.push({ ...base, claimId: inlineClaimId || claimId, severity: stringField(doc, "severity") })
200
+ }
201
+ }
202
+
203
+ const ok = diagnostics.every((diagnostic) => diagnostic.severity !== "error") && unresolvedRefs.length === 0
204
+ const relationCoverage = buildRelationCoverage(documents, claims, evidence, objections, risks, researchGaps, relations, ids)
205
+ const relationCandidates = buildRelationCandidates(documents, relations, ids)
206
+ return {
207
+ ok,
208
+ path: "revela-narrative",
209
+ counts: {
210
+ claims: claims.length,
211
+ evidence: evidence.length,
212
+ researchGaps: researchGaps.length,
213
+ objections: objections.length,
214
+ risks: risks.length,
215
+ relations: relations.length,
216
+ unresolvedRefs: unresolvedRefs.length,
217
+ },
218
+ claims,
219
+ evidence,
220
+ researchGaps,
221
+ objections,
222
+ risks,
223
+ relations,
224
+ relationCoverage,
225
+ relationSummary: {
226
+ inlineEdges: relations.length,
227
+ advisoryCandidates: relationCandidates.length,
228
+ },
229
+ relationCandidates,
230
+ unresolvedRefs,
231
+ idHints: buildIdHints(ids),
232
+ diagnostics,
233
+ }
234
+ }
235
+
236
+ function buildRelationCandidates(documents: VaultDocument[], relations: NarrativeVaultInventoryRelation[], ids: Set<string>): NarrativeVaultRelationCandidate[] {
237
+ const candidates: NarrativeVaultRelationCandidate[] = []
238
+ const addCandidate = (candidate: NarrativeVaultRelationCandidate) => {
239
+ if (!ids.has(candidate.fromId) || !ids.has(candidate.toId)) return
240
+ if (relations.some((edge) => edge.fromId === candidate.fromId && edge.toId === candidate.toId && edge.relation === candidate.relation)) return
241
+ if (candidates.some((edge) => edge.fromId === candidate.fromId && edge.toId === candidate.toId && edge.relation === candidate.relation)) return
242
+ candidates.push(candidate)
243
+ }
244
+
245
+ for (const doc of documents) {
246
+ const id = nodeId(doc)
247
+ const type = nodeType(doc)
248
+ if (!id || !type) continue
249
+ if (type === "evidence") {
250
+ addFrontmatterCandidate(doc, id, "claimId", "supports", "Evidence frontmatter points at this claim.", addCandidate)
251
+ } else if (type === "objection") {
252
+ addFrontmatterCandidate(doc, id, "claimId", "answers", "Objection frontmatter points at this claim.", addCandidate)
253
+ } else if (type === "risk") {
254
+ addFrontmatterCandidate(doc, id, "claimId", "constrains", "Risk frontmatter points at this claim.", addCandidate)
255
+ } else if (type === "research-gap") {
256
+ addFrontmatterCandidate(doc, id, "targetId", "depends_on", "Research gap frontmatter points at this target.", addCandidate)
257
+ }
258
+ }
259
+
260
+ return candidates.sort((a, b) => a.id.localeCompare(b.id))
261
+ }
262
+
263
+ function addFrontmatterCandidate(
264
+ doc: VaultDocument,
265
+ fromId: string,
266
+ targetField: string,
267
+ relation: NarrativeClaimRelationType,
268
+ rationale: string,
269
+ addCandidate: (candidate: NarrativeVaultRelationCandidate) => void,
270
+ ): void {
271
+ const toId = stringField(doc, targetField)
272
+ if (!toId) return
273
+ addCandidate({ id: candidateRelationId(fromId, relation, toId), fromId, toId, relation, rationale, source: "frontmatter" })
274
+ }
275
+
276
+ function candidateRelationId(fromId: string, relation: NarrativeClaimRelationType, toId: string): string {
277
+ return stableVaultRelationId(fromId, relation, toId)
278
+ }
279
+
280
+ function buildRelationCoverage(
281
+ documents: VaultDocument[],
282
+ claims: NarrativeVaultInventoryClaim[],
283
+ evidence: NarrativeVaultInventoryEvidence[],
284
+ objections: NarrativeVaultInventoryObjection[],
285
+ risks: NarrativeVaultInventoryRisk[],
286
+ researchGaps: NarrativeVaultInventoryResearchGap[],
287
+ relations: NarrativeVaultInventoryRelation[],
288
+ ids: Set<string>,
289
+ ): NarrativeVaultRelationCoverage {
290
+ const connected = new Set<string>()
291
+ for (const relation of relations) {
292
+ if (ids.has(relation.fromId)) connected.add(relation.fromId)
293
+ if (ids.has(relation.toId)) connected.add(relation.toId)
294
+ }
295
+ const relationToClaim = (id: string, relation: string) => relations.some((edge) => edge.fromId === id && edge.relation === relation && claims.some((claim) => claim.id === edge.toId))
296
+ const claimLinked = (id: string) => relations.some((edge) => (edge.fromId === id && claims.some((claim) => claim.id === edge.toId)) || (edge.toId === id && claims.some((claim) => claim.id === edge.fromId)))
297
+ const fallbackOnlyBindings = buildFallbackOnlyBindings(documents, relations, ids)
298
+ return {
299
+ danglingEdges: relations.filter((relation) => relation.unresolved),
300
+ unboundEvidence: evidence.filter((item) => !item.claimId && !relationToClaim(item.id, "supports")).map((item) => item.id),
301
+ unboundObjections: objections.filter((item) => !item.claimId && !relationToClaim(item.id, "answers") && !relationToClaim(item.id, "contrasts_with")).map((item) => item.id),
302
+ unboundRisks: risks.filter((item) => !item.claimId && !relationToClaim(item.id, "constrains")).map((item) => item.id),
303
+ unboundResearchGaps: researchGaps.filter((item) => !item.targetId && !relations.some((edge) => edge.fromId === item.id)).map((item) => item.id),
304
+ fallbackOnlyBindings,
305
+ isolatedClaims: claims.filter((claim) => claim.importance === "central" && claims.length > 1 && !claimLinked(claim.id)).map((claim) => claim.id),
306
+ orphanNodes: [...claims, ...evidence, ...objections, ...risks, ...researchGaps].filter((item) => !connected.has(item.id)).map((item) => item.id),
307
+ inlineRelations: documents.filter((doc) => doc.relations.length > 0).map((doc) => ({ file: doc.relativePath, nodeId: nodeId(doc) })),
308
+ }
309
+ }
310
+
311
+ function buildFallbackOnlyBindings(documents: VaultDocument[], relations: NarrativeVaultInventoryRelation[], ids: Set<string>): NarrativeVaultRelationCoverage["fallbackOnlyBindings"] {
312
+ const items: NarrativeVaultRelationCoverage["fallbackOnlyBindings"] = []
313
+ const add = (doc: VaultDocument, field: string, relation: NarrativeClaimRelationType) => {
314
+ const node = nodeId(doc)
315
+ const targetId = stringField(doc, field)
316
+ if (!node || !targetId || !ids.has(targetId)) return
317
+ if (relations.some((edge) => edge.fromId === node && edge.toId === targetId && edge.relation === relation)) return
318
+ items.push({ nodeId: node, file: doc.relativePath, field, relation, targetId })
319
+ }
320
+ for (const doc of documents) {
321
+ const type = nodeType(doc)
322
+ if (type === "evidence") add(doc, "claimId", "supports")
323
+ else if (type === "objection") add(doc, "claimId", "answers")
324
+ else if (type === "risk") add(doc, "claimId", "constrains")
325
+ else if (type === "research-gap") add(doc, "targetId", "depends_on")
326
+ }
327
+ return items
328
+ }
329
+
330
+ function relationTargetFor(relations: NarrativeVaultInventoryRelation[], fromId: string, relation: string, targetIds: Set<string>): string {
331
+ return relations.find((edge) => edge.fromId === fromId && edge.relation === relation && targetIds.has(edge.toId))?.toId ?? ""
332
+ }
333
+
334
+ function baseNode(doc: VaultDocument, id: string, type: VaultNodeType): NarrativeVaultInventoryNode {
335
+ return {
336
+ id,
337
+ type,
338
+ file: doc.relativePath,
339
+ title: stringField(doc, "title"),
340
+ text: firstText(doc.body),
341
+ }
342
+ }
343
+
344
+ function nodeId(doc: VaultDocument): string {
345
+ return stringField(doc, "id")
346
+ }
347
+
348
+ function nodeType(doc: VaultDocument): VaultNodeType | "" {
349
+ const type = stringField(doc, "type")
350
+ return isVaultNodeType(type) ? type : ""
351
+ }
352
+
353
+ function isVaultNodeType(type: string): type is VaultNodeType {
354
+ return ["index", "audience", "decision", "thesis", "claim", "evidence", "objection", "risk", "research-gap"].includes(type)
355
+ }
356
+
357
+ function stringField(doc: VaultDocument, key: string): string {
358
+ const value = doc.frontmatter[key]
359
+ return typeof value === "string" ? value.trim() : ""
360
+ }
361
+
362
+ function booleanField(doc: VaultDocument, key: string): boolean {
363
+ return doc.frontmatter[key] === true
364
+ }
365
+
366
+ function firstText(markdown: string): string {
367
+ return markdown
368
+ .split(/\n+/)
369
+ .map((line) => line.replace(/^#+\s+/, "").trim())
370
+ .find((line) => line && !line.startsWith("- ")) ?? ""
371
+ }
372
+
373
+ function isLooseTargetType(targetType: string): boolean {
374
+ return !targetType || targetType === "narrative" || targetType === "decision"
375
+ }
376
+
377
+ function buildIdHints(ids: Set<string>): NarrativeVaultInventory["idHints"] {
378
+ return {
379
+ nextClaimIdExamples: [nextAvailableId("claim-market-context", ids), nextAvailableId("claim-recommendation", ids)],
380
+ nextEvidenceIdExamples: [nextAvailableId("evidence-source-quote", ids), nextAvailableId("evidence-research-finding", ids)],
381
+ nextResearchGapIdExamples: [nextAvailableId("gap-market-size", ids), nextAvailableId("gap-source-validation", ids)],
382
+ }
383
+ }
384
+
385
+ function nextAvailableId(base: string, ids: Set<string>): string {
386
+ if (!ids.has(base)) return base
387
+ for (let index = 2; index < 100; index += 1) {
388
+ const candidate = `${base}-${index}`
389
+ if (!ids.has(candidate)) return candidate
390
+ }
391
+ return `${base}-${Date.now()}`
392
+ }
@@ -0,0 +1,237 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs"
2
+ import { join } from "path"
3
+ import { readNarrativeVaultDocuments } from "./read"
4
+ import { inspectVaultMarkdown } from "./authoring-guard"
5
+ import { buildNarrativeVaultInventory, type NarrativeVaultInventoryUnresolvedRef } from "./inventory"
6
+ import { narrativeVaultPath } from "./paths"
7
+ import type { VaultDiagnosticDisplay } from "./diagnostic-report"
8
+ import type { VaultDocument } from "./types"
9
+
10
+ export interface MarkdownQaRepairCard {
11
+ severity: "error" | "warning"
12
+ file: string
13
+ nodeId?: string
14
+ issueCode: string
15
+ message: string
16
+ smallestRepair: string
17
+ examples?: string[]
18
+ }
19
+
20
+ export interface MarkdownQaReport {
21
+ ok: boolean
22
+ repairCards: MarkdownQaRepairCard[]
23
+ blockers: MarkdownQaRepairCard[]
24
+ warnings: MarkdownQaRepairCard[]
25
+ }
26
+
27
+ export interface MarkdownQaOptions {
28
+ touched?: string[]
29
+ scope?: "touched" | "affected" | "full"
30
+ strictness?: "authoring" | "readiness" | "render"
31
+ }
32
+
33
+ export function runNarrativeMarkdownQa(workspaceRoot: string, touchedOrOptions?: string[] | MarkdownQaOptions): MarkdownQaReport {
34
+ const options = Array.isArray(touchedOrOptions) ? { touched: touchedOrOptions, scope: "touched" as const, strictness: "authoring" as const } : touchedOrOptions ?? {}
35
+ const { documents } = readNarrativeVaultDocuments(workspaceRoot)
36
+ const scope = options.scope ?? (options.touched ? "touched" : "full")
37
+ const strictness = options.strictness ?? "authoring"
38
+ const touchedSet = options.touched ? new Set(options.touched.map(normalizeVaultFile).filter(Boolean)) : undefined
39
+ const selected = touchedSet ? documents.filter((doc) => touchedSet.has(doc.relativePath)) : documents
40
+ const inventory = buildNarrativeVaultInventory(workspaceRoot)
41
+ const repairCards: MarkdownQaRepairCard[] = []
42
+
43
+ for (const doc of selected) {
44
+ repairCards.push(...inspectVaultMarkdown(doc.relativePath, readFileSync(doc.path, "utf-8"))
45
+ .filter((diagnostic) => !isEvidenceClaimIdCoveredByInlineRelation(doc, diagnostic.code, inventory))
46
+ .map(cardFromDiagnostic))
47
+ repairCards.push(...evidenceTraceCards(doc))
48
+ }
49
+
50
+ repairCards.push(...unsupportedRootMarkdownCards(workspaceRoot, touchedSet))
51
+
52
+ for (const unresolved of inventory.unresolvedRefs) {
53
+ if (touchedSet && !touchedSet.has(unresolved.file)) continue
54
+ repairCards.push(cardFromUnresolved(unresolved))
55
+ }
56
+ for (const diagnostic of inventory.diagnostics) {
57
+ if (touchedSet && diagnostic.file && !touchedSet.has(diagnostic.file)) continue
58
+ repairCards.push(cardFromDiagnostic({
59
+ severity: diagnostic.severity,
60
+ code: diagnostic.code,
61
+ message: diagnostic.message,
62
+ file: diagnostic.file,
63
+ nodeId: diagnostic.nodeId,
64
+ suggestedFix: diagnostic.code === "unknown_relation_type" ? "Use one of: leads_to, supports, depends_on, contrasts_with, constrains, answers." : "Repair the Markdown node.",
65
+ suggestedAction: "Repair the Markdown relation line and rerun markdownQa.",
66
+ }))
67
+ }
68
+ if (scope !== "touched") repairCards.push(...relationCoverageCards(inventory, strictness))
69
+
70
+ const deduped = dedupeCards(repairCards)
71
+ const blockers = deduped.filter((card) => card.severity === "error")
72
+ const warnings = deduped.filter((card) => card.severity === "warning")
73
+ return { ok: blockers.length === 0, repairCards: deduped, blockers, warnings }
74
+ }
75
+
76
+ function isEvidenceClaimIdCoveredByInlineRelation(doc: VaultDocument, issueCode: string, inventory: ReturnType<typeof buildNarrativeVaultInventory>): boolean {
77
+ if (issueCode !== "evidence_claim_id_missing_authoring") return false
78
+ const id = stringField(doc, "id")
79
+ if (!id) return false
80
+ return inventory.relations.some((relation) => relation.fromId === id && relation.relation === "supports" && !relation.unresolved)
81
+ }
82
+
83
+ function relationCoverageCards(inventory: ReturnType<typeof buildNarrativeVaultInventory>, strictness: NonNullable<MarkdownQaOptions["strictness"]>): MarkdownQaRepairCard[] {
84
+ const cards: MarkdownQaRepairCard[] = []
85
+ const severity = strictness === "authoring" ? "warning" : "error"
86
+ for (const id of inventory.relationCoverage.unboundEvidence) cards.push(relationCoverageCard(severity, id, "unbound_evidence", "Evidence node has no claim-support relation.", "Add `## Relations` with `- supports: [[claim-id]]`, or bind the evidence through bindResearchFindings."))
87
+ for (const id of inventory.relationCoverage.unboundObjections) cards.push(relationCoverageCard(severity, id, "unbound_objection", "Objection node has no claim relation.", "Add `## Relations` with `- answers: [[claim-id]]` or `- contrasts_with: [[claim-id]]`, or set an explicit claim target if preserving existing shape."))
88
+ for (const id of inventory.relationCoverage.unboundRisks) cards.push(relationCoverageCard(severity, id, "unbound_risk", "Risk node has no claim relation.", "Add `## Relations` with `- constrains: [[claim-id]]`, or set an explicit claim target if preserving existing shape."))
89
+ for (const id of inventory.relationCoverage.unboundResearchGaps) cards.push(relationCoverageCard("warning", id, "unbound_research_gap", "Research gap has no target relation or target id.", "Add `## Relations` with `- depends_on: [[target-node-id]]` or a targetId after checking narrativeInventory."))
90
+ for (const item of inventory.relationCoverage.fallbackOnlyBindings) {
91
+ cards.push({
92
+ severity: "warning",
93
+ file: item.file,
94
+ nodeId: item.nodeId,
95
+ issueCode: "frontmatter_binding_without_relation",
96
+ message: `${item.field} is only expressed in frontmatter; wikilink-first vaults should express this graph edge in ## Relations.`,
97
+ smallestRepair: `Add \`## Relations\` with \`- ${item.relation}: [[${item.targetId}]]\`; keep frontmatter only as compatibility fallback if needed.`,
98
+ })
99
+ }
100
+ for (const id of inventory.relationCoverage.isolatedClaims) cards.push(relationCoverageCard(strictness === "render" ? "error" : "warning", id, "isolated_central_claim", "Central claim is not connected to the claim-flow graph.", "Add a claim-flow edge in the source node `## Relations`, or downgrade importance if it is background context."))
101
+ return cards
102
+ }
103
+
104
+ function relationCoverageCard(severity: "error" | "warning", nodeId: string, issueCode: string, message: string, smallestRepair: string): MarkdownQaRepairCard {
105
+ return { severity, file: "revela-narrative", nodeId, issueCode, message, smallestRepair }
106
+ }
107
+
108
+ function cardFromDiagnostic(diagnostic: VaultDiagnosticDisplay): MarkdownQaRepairCard {
109
+ return {
110
+ severity: diagnostic.severity,
111
+ file: diagnostic.file ?? "revela-narrative",
112
+ nodeId: diagnostic.nodeId,
113
+ issueCode: diagnostic.code,
114
+ message: diagnostic.message,
115
+ smallestRepair: diagnostic.suggestedFix || diagnostic.suggestedAction || "Repair the Markdown node and rerun markdownQa.",
116
+ examples: examplesFor(diagnostic.code),
117
+ }
118
+ }
119
+
120
+ function cardFromUnresolved(ref: NarrativeVaultInventoryUnresolvedRef): MarkdownQaRepairCard {
121
+ const target = ref.targetId || "<missing>"
122
+ if (ref.kind === "relation") {
123
+ return {
124
+ severity: "error",
125
+ file: ref.file,
126
+ nodeId: ref.fromId,
127
+ issueCode: "broken_relation_target",
128
+ message: `Relation points to unknown node ${target}.`,
129
+ smallestRepair: "Create the referenced node or patch the relation wikilink to an existing id from narrativeInventory.",
130
+ examples: ["- supports: [[claim-existing-id]]"],
131
+ }
132
+ }
133
+ if (ref.kind === "evidenceClaimId") {
134
+ return {
135
+ severity: "error",
136
+ file: ref.file,
137
+ nodeId: ref.fromId,
138
+ issueCode: ref.targetId ? "unresolved_evidence_claim_id" : "missing_evidence_claim_id",
139
+ message: ref.targetId ? `Evidence references unknown claimId ${target}.` : "Evidence is missing a claim-support relation.",
140
+ smallestRepair: "Add `## Relations` with `- supports: [[claim-id]]` after checking narrativeInventory, or create the missing claim node first.",
141
+ examples: ["- supports: [[claim-market-context]]"],
142
+ }
143
+ }
144
+ return {
145
+ severity: "warning",
146
+ file: ref.file,
147
+ nodeId: ref.fromId,
148
+ issueCode: `unresolved_${ref.kind}`,
149
+ message: `${ref.field ?? "target"} references unknown node ${target}.`,
150
+ smallestRepair: "Check narrativeInventory and patch the target id, or create the missing target node if intentional.",
151
+ }
152
+ }
153
+
154
+ function evidenceTraceCards(doc: VaultDocument): MarkdownQaRepairCard[] {
155
+ if (stringField(doc, "type") !== "evidence") return []
156
+ const cards: MarkdownQaRepairCard[] = []
157
+ const missing: string[] = []
158
+ if (!hasSource(doc)) missing.push("source|sourcePath|url|findingsFile")
159
+ if (!stringField(doc, "quote") && !firstBodyLine(doc.body)) missing.push("quote|snippet body")
160
+ for (const field of ["supportScope", "unsupportedScope", "caveat", "strength"]) {
161
+ if (!stringField(doc, field)) missing.push(field)
162
+ }
163
+ if (missing.length > 0) {
164
+ cards.push({
165
+ severity: "warning",
166
+ file: doc.relativePath,
167
+ nodeId: stringField(doc, "id"),
168
+ issueCode: "evidence_trace_fields_missing",
169
+ message: `Evidence node is missing trace field(s): ${missing.join(", ")}.`,
170
+ smallestRepair: "Add explicit source trace, quote/snippet, support scope, unsupported scope, caveat, and strength before treating the evidence as strong support.",
171
+ examples: ["sourcePath: proposal.md", "supportScope: Scope explicitly supported by the quote.", "unsupportedScope: What this evidence does not prove."],
172
+ })
173
+ }
174
+ return cards
175
+ }
176
+
177
+ function unsupportedRootMarkdownCards(workspaceRoot: string, touchedSet?: Set<string>): MarkdownQaRepairCard[] {
178
+ const root = narrativeVaultPath(workspaceRoot)
179
+ if (!existsSync(root) || !statSync(root).isDirectory()) return []
180
+ const supportedRootFiles = new Set(["index.md", "audience.md", "decision.md", "thesis.md"])
181
+ const cards: MarkdownQaRepairCard[] = []
182
+
183
+ for (const entry of readdirSync(root).sort()) {
184
+ if (!entry.endsWith(".md") || supportedRootFiles.has(entry)) continue
185
+ const filePath = join(root, entry)
186
+ if (!statSync(filePath).isFile()) continue
187
+ const relativePath = entry.replace(/\\/g, "/")
188
+ if (touchedSet && !touchedSet.has(relativePath) && !touchedSet.has(`revela-narrative/${relativePath}`)) continue
189
+ cards.push({
190
+ severity: "warning",
191
+ file: relativePath,
192
+ issueCode: "unsupported_vault_root_markdown",
193
+ message: "Markdown file is in the vault root but only index.md, audience.md, decision.md, and thesis.md are supported there.",
194
+ smallestRepair: "Move claim/evidence/objection/risk/research-gap nodes into their supported subdirectory, or remove this unsupported root file if it was temporary.",
195
+ examples: ["claims/claim-market-context.md", "evidence/evidence-proposal-intent.md", "research-gaps/gap-market-size.md"],
196
+ })
197
+ }
198
+
199
+ return cards
200
+ }
201
+
202
+ function normalizeVaultFile(file: string): string {
203
+ return file.replace(/\\/g, "/").replace(/^revela-narrative\//, "")
204
+ }
205
+
206
+ function stringField(doc: VaultDocument, key: string): string {
207
+ const value = doc.frontmatter[key]
208
+ return typeof value === "string" ? value.trim() : ""
209
+ }
210
+
211
+ function hasSource(doc: VaultDocument): boolean {
212
+ return Boolean(stringField(doc, "source") || stringField(doc, "sourcePath") || stringField(doc, "url") || stringField(doc, "findingsFile"))
213
+ }
214
+
215
+ function firstBodyLine(body: string): string {
216
+ return body.split(/\n+/).map((line) => line.trim()).find(Boolean) ?? ""
217
+ }
218
+
219
+ function examplesFor(code: string): string[] | undefined {
220
+ if (code === "typed_wikilink_target") return ["- supports: [[claim-existing-id]]"]
221
+ if (code === "invalid_node_type_authoring") return ["type: research-gap"]
222
+ if (code === "duplicate_stable_heading") return ["Merge duplicate section bodies under one ## Caveats or ## Relations heading."]
223
+ if (code === "duplicate_frontmatter") return ["Keep exactly one leading --- frontmatter block."]
224
+ return undefined
225
+ }
226
+
227
+ function dedupeCards(cards: MarkdownQaRepairCard[]): MarkdownQaRepairCard[] {
228
+ const seen = new Set<string>()
229
+ const result: MarkdownQaRepairCard[] = []
230
+ for (const card of cards) {
231
+ const key = [card.severity, card.file, card.nodeId ?? "", card.issueCode, card.message].join("\0")
232
+ if (seen.has(key)) continue
233
+ seen.add(key)
234
+ result.push(card)
235
+ }
236
+ return result
237
+ }
@@ -0,0 +1,34 @@
1
+ export function splitMarkdownSections(body: string): { main: string; sections: Record<string, string> } {
2
+ const sections: Record<string, string> = {}
3
+ const lines = body.replace(/\r\n/g, "\n").split("\n")
4
+ let current = "main"
5
+ const buffers: Record<string, string[]> = { main: [] }
6
+ for (const line of lines) {
7
+ const match = /^##\s+(.+?)\s*$/.exec(line)
8
+ if (match) {
9
+ current = normalizeSectionName(match[1])
10
+ buffers[current] = []
11
+ continue
12
+ }
13
+ buffers[current].push(line)
14
+ }
15
+ for (const [key, value] of Object.entries(buffers)) {
16
+ if (key === "main") continue
17
+ sections[key] = value.join("\n").trim()
18
+ }
19
+ return { main: buffers.main.join("\n").trim(), sections }
20
+ }
21
+
22
+ export function firstParagraphOrBody(value: string): string {
23
+ const cleaned = value.trim()
24
+ if (!cleaned) return ""
25
+ return cleaned.split(/\n\s*\n/)[0].replace(/\n+/g, " ").trim()
26
+ }
27
+
28
+ export function parseMarkdownList(section: string): string[] {
29
+ return section.split("\n").map((line) => line.trim()).filter((line) => line.startsWith("- ")).map((line) => line.slice(2).trim()).filter(Boolean)
30
+ }
31
+
32
+ export function normalizeSectionName(name: string): string {
33
+ return name.trim().toLowerCase().replace(/\s+/g, "-")
34
+ }