@cyber-dash-tech/revela 0.16.4 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/README.md +7 -5
  2. package/README.zh-CN.md +7 -5
  3. package/lib/commands/brief.ts +9 -0
  4. package/lib/commands/help.ts +5 -2
  5. package/lib/commands/init.ts +42 -27
  6. package/lib/commands/narrative.ts +26 -2
  7. package/lib/commands/research.ts +36 -20
  8. package/lib/commands/review.ts +21 -18
  9. package/lib/ctx.ts +1 -1
  10. package/lib/decks-state.ts +38 -4
  11. package/lib/edit/prompt.ts +1 -1
  12. package/lib/hook-notifications.ts +53 -0
  13. package/lib/narrative-state/render-plan.ts +114 -27
  14. package/lib/narrative-state/research-binding-eval.ts +260 -0
  15. package/lib/narrative-state/research-gaps.ts +2 -88
  16. package/lib/narrative-vault/authoring-contract.ts +127 -0
  17. package/lib/narrative-vault/authoring-guard.ts +122 -0
  18. package/lib/narrative-vault/auto-compile.ts +134 -0
  19. package/lib/narrative-vault/bootstrap.ts +63 -0
  20. package/lib/narrative-vault/cache.ts +14 -0
  21. package/lib/narrative-vault/compile-mirror.ts +45 -0
  22. package/lib/narrative-vault/compile.ts +350 -0
  23. package/lib/narrative-vault/constants.ts +6 -0
  24. package/lib/narrative-vault/diagnostic-report.ts +117 -0
  25. package/lib/narrative-vault/export.ts +71 -0
  26. package/lib/narrative-vault/frontmatter.ts +41 -0
  27. package/lib/narrative-vault/hook-targets.ts +40 -0
  28. package/lib/narrative-vault/index.ts +18 -0
  29. package/lib/narrative-vault/inventory.ts +392 -0
  30. package/lib/narrative-vault/markdown-qa.ts +237 -0
  31. package/lib/narrative-vault/markdown.ts +34 -0
  32. package/lib/narrative-vault/migration.ts +52 -0
  33. package/lib/narrative-vault/mutate.ts +361 -0
  34. package/lib/narrative-vault/paths.ts +19 -0
  35. package/lib/narrative-vault/read.ts +52 -0
  36. package/lib/narrative-vault/relations.ts +32 -0
  37. package/lib/narrative-vault/source-loader.ts +19 -0
  38. package/lib/narrative-vault/timestamp.ts +32 -0
  39. package/lib/narrative-vault/types.ts +44 -0
  40. package/lib/source-materials.ts +98 -0
  41. package/lib/tool-result.ts +34 -0
  42. package/package.json +2 -2
  43. package/plugin.ts +60 -22
  44. package/skill/NARRATIVE_SKILL.md +25 -10
  45. package/tools/decks.ts +363 -67
  46. package/tools/research-save.ts +3 -0
  47. package/tools/workspace-scan.ts +1 -0
@@ -0,0 +1,260 @@
1
+ import { existsSync, readFileSync } from "fs"
2
+ import { resolve, sep } from "path"
3
+ import type { DecksState } from "../decks-state"
4
+ import { normalizeNarrativeState } from "./normalize"
5
+ import type { NarrativeClaim, NarrativeEvidenceBinding, NarrativeStateV1 } from "./types"
6
+
7
+ export type EvidenceBindingFailureReason =
8
+ | "missing_quote"
9
+ | "unclear_source"
10
+ | "over_broad_claim"
11
+ | "weak_source"
12
+ | "unsupported_scope"
13
+ | "caveat_conflict"
14
+ | "source_mismatch"
15
+ | "context_only_finding"
16
+
17
+ export interface EvidenceBindingDiagnostic {
18
+ findingsFile: string
19
+ bindable: boolean
20
+ failureReasons: EvidenceBindingFailureReason[]
21
+ explicit: {
22
+ source: boolean
23
+ quoteOrSnippet: boolean
24
+ supportScope: boolean
25
+ unsupportedScope: boolean
26
+ caveat: boolean
27
+ strength: boolean
28
+ }
29
+ }
30
+
31
+ export type ResearchFindingsBindingStatus = "bindable" | "needs_fields" | "not_relevant" | "unsafe"
32
+
33
+ export interface ResearchFindingsBindingEval {
34
+ findingsFile: string
35
+ status: ResearchFindingsBindingStatus
36
+ claimId?: string
37
+ claimText?: string
38
+ diagnostic?: EvidenceBindingDiagnostic
39
+ missingFields: Array<"source" | "quoteOrSnippet" | "supportScope" | "unsupportedScope" | "caveat" | "strength" | "claimId">
40
+ failureReasons: EvidenceBindingFailureReason[]
41
+ recommendedEvidenceDraft?: Partial<NarrativeEvidenceBinding>
42
+ nextAction: string
43
+ }
44
+
45
+ export function evaluateResearchFindingsBinding(state: DecksState, workspaceRoot: string | undefined, findingsFile: string): ResearchFindingsBindingEval {
46
+ const normalizedFile = normalizeResearchFindingsPath(findingsFile)
47
+ if (!normalizedFile) return unsafeEval(findingsFile, "Use a workspace-relative researches/**/*.md findings file before evaluating evidence binding.")
48
+
49
+ const text = readWorkspaceText(workspaceRoot, normalizedFile)
50
+ if (text === undefined) return unsafeEval(normalizedFile, "Save the findings file inside the workspace before evaluating evidence binding.")
51
+
52
+ const narrative = normalizeNarrativeState(state)
53
+ const diagnostic = evidenceBindingDiagnosticFromText(normalizedFile, text)
54
+ const claim = resolveClaimForFindings(narrative, normalizedFile, text)
55
+ const explicitClaimId = extractField(text, ["claimId", "claim id"])
56
+ const missingFields = missingExplicitFields(diagnostic)
57
+ const failureReasons = [...diagnostic.failureReasons]
58
+
59
+ if (explicitClaimId && !claim) {
60
+ return {
61
+ findingsFile: normalizedFile,
62
+ status: "unsafe",
63
+ claimId: explicitClaimId,
64
+ diagnostic,
65
+ missingFields,
66
+ failureReasons: [...new Set([...failureReasons, "source_mismatch" as const])],
67
+ nextAction: `Do not bind ${normalizedFile}: claimId ${explicitClaimId} does not exist in the canonical narrative.`,
68
+ }
69
+ }
70
+
71
+ if (!claim) {
72
+ return {
73
+ findingsFile: normalizedFile,
74
+ status: diagnostic.failureReasons.includes("context_only_finding") ? "not_relevant" : "needs_fields",
75
+ diagnostic,
76
+ missingFields: [...missingFields, "claimId"],
77
+ failureReasons,
78
+ nextAction: "Identify the exact canonical claimId this findings file supports before writing evidence/*.md.",
79
+ }
80
+ }
81
+
82
+ if (!diagnostic.bindable) {
83
+ return {
84
+ findingsFile: normalizedFile,
85
+ status: "needs_fields",
86
+ claimId: claim.id,
87
+ claimText: claim.text,
88
+ diagnostic,
89
+ missingFields,
90
+ failureReasons,
91
+ nextAction: `Do not bind yet. Fill missing fields for ${claim.id}: ${missingFields.join(", ") || "none"}.`,
92
+ }
93
+ }
94
+
95
+ return {
96
+ findingsFile: normalizedFile,
97
+ status: "bindable",
98
+ claimId: claim.id,
99
+ claimText: claim.text,
100
+ diagnostic,
101
+ missingFields,
102
+ failureReasons,
103
+ recommendedEvidenceDraft: buildEvidenceDraft(normalizedFile, text, claim),
104
+ nextAction: `Write a canonical evidence node in revela-narrative/evidence/ for ${claim.id}, preserving source trace, quote, scopes, caveat, and strength.`,
105
+ }
106
+ }
107
+
108
+ export function evidenceBindingDiagnostic(workspaceRoot: string | undefined, findingsFile: string): EvidenceBindingDiagnostic | undefined {
109
+ const text = readWorkspaceText(workspaceRoot, findingsFile)
110
+ if (text === undefined) return undefined
111
+ return evidenceBindingDiagnosticFromText(findingsFile, text)
112
+ }
113
+
114
+ function evidenceBindingDiagnosticFromText(findingsFile: string, text: string): EvidenceBindingDiagnostic {
115
+ const explicit = {
116
+ source: hasSourceTrace(text),
117
+ quoteOrSnippet: hasQuoteOrSnippet(text),
118
+ supportScope: hasField(text, ["support scope", "supported scope", "supports", "support"]),
119
+ unsupportedScope: hasField(text, ["unsupported scope", "unsupported", "not supported", "gaps"]),
120
+ caveat: hasField(text, ["caveat", "limitation", "limits", "boundary"]),
121
+ strength: hasField(text, ["strength", "support strength", "evidence strength"]),
122
+ }
123
+ const failureReasons: EvidenceBindingFailureReason[] = []
124
+ if (!explicit.quoteOrSnippet) failureReasons.push("missing_quote")
125
+ if (!explicit.source) failureReasons.push("unclear_source")
126
+ if (!explicit.supportScope || !explicit.unsupportedScope) failureReasons.push("unsupported_scope")
127
+ if (!explicit.caveat) failureReasons.push("caveat_conflict")
128
+ if (!explicit.strength) failureReasons.push("weak_source")
129
+ if (looksContextOnly(text, explicit)) failureReasons.push("context_only_finding")
130
+ return { findingsFile, bindable: failureReasons.length === 0, failureReasons, explicit }
131
+ }
132
+
133
+ function resolveClaimForFindings(narrative: NarrativeStateV1, findingsFile: string, text: string): NarrativeClaim | undefined {
134
+ const explicitClaimId = extractField(text, ["claimId", "claim id"])
135
+ if (explicitClaimId) return narrative.claims.find((claim) => claim.id === explicitClaimId)
136
+ const gap = (narrative.researchGaps ?? []).find((item) => item.findingsFile === findingsFile && item.targetType === "claim" && item.targetId)
137
+ if (gap?.targetId) return narrative.claims.find((claim) => claim.id === gap.targetId)
138
+ return undefined
139
+ }
140
+
141
+ function buildEvidenceDraft(findingsFile: string, text: string, claim: NarrativeClaim): Partial<NarrativeEvidenceBinding> {
142
+ const source = extractSource(text) ?? findingsFile
143
+ const draft: Partial<NarrativeEvidenceBinding> = {
144
+ claimId: claim.id,
145
+ source,
146
+ findingsFile,
147
+ quote: extractQuote(text),
148
+ supportScope: extractField(text, ["support scope", "supported scope", "support"]),
149
+ unsupportedScope: extractField(text, ["unsupported scope", "unsupported", "not supported"]),
150
+ caveat: extractField(text, ["caveat", "limitation", "limits", "boundary"]),
151
+ strength: normalizeStrength(extractField(text, ["strength", "support strength", "evidence strength"])),
152
+ }
153
+ if (/^https?:\/\//i.test(source)) draft.url = source
154
+ else draft.sourcePath = source
155
+ return draft
156
+ }
157
+
158
+ function unsafeEval(findingsFile: string, nextAction: string): ResearchFindingsBindingEval {
159
+ return {
160
+ findingsFile,
161
+ status: "unsafe",
162
+ missingFields: ["source", "quoteOrSnippet", "supportScope", "unsupportedScope", "caveat", "strength", "claimId"],
163
+ failureReasons: ["source_mismatch"],
164
+ nextAction,
165
+ }
166
+ }
167
+
168
+ function missingExplicitFields(diagnostic: EvidenceBindingDiagnostic): ResearchFindingsBindingEval["missingFields"] {
169
+ const missing: ResearchFindingsBindingEval["missingFields"] = []
170
+ if (!diagnostic.explicit.source) missing.push("source")
171
+ if (!diagnostic.explicit.quoteOrSnippet) missing.push("quoteOrSnippet")
172
+ if (!diagnostic.explicit.supportScope) missing.push("supportScope")
173
+ if (!diagnostic.explicit.unsupportedScope) missing.push("unsupportedScope")
174
+ if (!diagnostic.explicit.caveat) missing.push("caveat")
175
+ if (!diagnostic.explicit.strength) missing.push("strength")
176
+ return missing
177
+ }
178
+
179
+ function normalizeResearchFindingsPath(filePath: string | undefined): string | undefined {
180
+ const normalized = normalizePath(filePath ?? "").replace(/^\.\//, "")
181
+ if (!normalized || normalized.startsWith("../") || normalized.startsWith("/")) return undefined
182
+ if (!normalized.startsWith("researches/") || !normalized.endsWith(".md")) return undefined
183
+ return normalized
184
+ }
185
+
186
+ function readWorkspaceText(workspaceRoot: string | undefined, relativePath: string): string | undefined {
187
+ if (!workspaceRoot) return undefined
188
+ const root = resolve(workspaceRoot)
189
+ const target = resolve(root, relativePath)
190
+ if (target !== root && !target.startsWith(root + sep)) return undefined
191
+ if (!existsSync(target)) return undefined
192
+ return readFileSync(target, "utf-8")
193
+ }
194
+
195
+ function hasSourceTrace(text: string): boolean {
196
+ return /^sources:\s*$/im.test(text)
197
+ || /\[source:\s*[^\]]+\]/i.test(text)
198
+ || /^\s*-?\s*source:\s*\S+/im.test(text)
199
+ || /^\s*source\s+(path|url):\s*\S+/im.test(text)
200
+ }
201
+
202
+ function hasQuoteOrSnippet(text: string): boolean {
203
+ return hasField(text, ["quote", "snippet"])
204
+ || /["“][^"”]{20,}["”]/.test(text)
205
+ || /^>\s*\S.{20,}/m.test(text)
206
+ }
207
+
208
+ function hasField(text: string, labels: string[]): boolean {
209
+ return labels.some((label) => new RegExp(`(^|\\n)\\s*(?:[-*]\\s*)?${escapeRegex(label)}\\s*[::]\\s*\\S`, "i").test(text)
210
+ || new RegExp(`^##+\\s+.*${escapeRegex(label)}`, "im").test(text))
211
+ }
212
+
213
+ function looksContextOnly(text: string, explicit: EvidenceBindingDiagnostic["explicit"]): boolean {
214
+ return /^##+\s+data\b/im.test(text) && !explicit.quoteOrSnippet && (!explicit.supportScope || !explicit.caveat)
215
+ }
216
+
217
+ function extractField(text: string, labels: string[]): string | undefined {
218
+ for (const label of labels) {
219
+ const match = text.match(new RegExp(`(^|\\n)\\s*(?:[-*]\\s*)?${escapeRegex(label)}\\s*[::]\\s*([^\\n]+)`, "i"))
220
+ const value = match?.[2]?.trim()
221
+ if (value) return value
222
+ }
223
+ return undefined
224
+ }
225
+
226
+ function extractSource(text: string): string | undefined {
227
+ const yamlSource = text.match(/^sources:\s*\n\s*-\s*["']?([^"'\n]+)["']?\s*$/im)?.[1]?.trim()
228
+ if (/^https?:\/\//i.test(yamlSource ?? "")) return yamlSource
229
+ const bracket = text.match(/\[source:\s*([^\]]+)\]/i)?.[1]?.trim()
230
+ if (bracket) return bracket
231
+ return extractField(text, ["source", "source url", "source path"])
232
+ }
233
+
234
+ function extractQuote(text: string): string | undefined {
235
+ const field = extractField(text, ["quote", "snippet"])
236
+ if (field) return trimQuote(field)
237
+ const quoted = text.match(/["“]([^"”]{20,})["”]/)?.[1]?.trim()
238
+ if (quoted) return quoted
239
+ return text.match(/^>\s*(\S.{20,})/m)?.[1]?.trim()
240
+ }
241
+
242
+ function normalizeStrength(value: string | undefined): NarrativeEvidenceBinding["strength"] | undefined {
243
+ const normalized = value?.toLowerCase()
244
+ if (normalized?.includes("strong")) return "strong"
245
+ if (normalized?.includes("partial")) return "partial"
246
+ if (normalized?.includes("weak")) return "weak"
247
+ return undefined
248
+ }
249
+
250
+ function trimQuote(value: string): string {
251
+ return value.replace(/^["“]/, "").replace(/["”]$/, "").trim()
252
+ }
253
+
254
+ function normalizePath(filePath: string): string {
255
+ return filePath.replace(/\\/g, "/")
256
+ }
257
+
258
+ function escapeRegex(value: string): string {
259
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
260
+ }
@@ -1,7 +1,6 @@
1
- import { existsSync, readFileSync } from "fs"
2
- import { resolve, sep } from "path"
3
1
  import type { DecksState } from "../decks-state"
4
2
  import { recordWorkspaceAction } from "../workspace-state/actions"
3
+ import { evidenceBindingDiagnostic, type EvidenceBindingDiagnostic, type EvidenceBindingFailureReason } from "./research-binding-eval"
5
4
  import { stableResearchGapId } from "./hash"
6
5
  import { normalizeNarrativeState } from "./normalize"
7
6
  import { reviewNarrativeState } from "./readiness"
@@ -24,15 +23,7 @@ export type ResearchTargetKind =
24
23
  | "unattached_findings"
25
24
  | "claim_chain_gap"
26
25
 
27
- export type EvidenceBindingFailureReason =
28
- | "missing_quote"
29
- | "unclear_source"
30
- | "over_broad_claim"
31
- | "weak_source"
32
- | "unsupported_scope"
33
- | "caveat_conflict"
34
- | "source_mismatch"
35
- | "context_only_finding"
26
+ export type { EvidenceBindingDiagnostic, EvidenceBindingFailureReason } from "./research-binding-eval"
36
27
 
37
28
  export interface ResearchTarget {
38
29
  id: string
@@ -56,20 +47,6 @@ export interface ResearchTargetsResult {
56
47
  selected?: ResearchTarget
57
48
  }
58
49
 
59
- export interface EvidenceBindingDiagnostic {
60
- findingsFile: string
61
- bindable: boolean
62
- failureReasons: EvidenceBindingFailureReason[]
63
- explicit: {
64
- source: boolean
65
- quoteOrSnippet: boolean
66
- supportScope: boolean
67
- unsupportedScope: boolean
68
- caveat: boolean
69
- strength: boolean
70
- }
71
- }
72
-
73
50
  export interface UpsertResearchGapInput {
74
51
  id?: string
75
52
  targetType?: NarrativeResearchGapTargetType
@@ -325,69 +302,6 @@ function targetsFromUnattachedFindings(state: DecksState, narrative: NarrativeSt
325
302
  })
326
303
  }
327
304
 
328
- function evidenceBindingDiagnostic(workspaceRoot: string | undefined, findingsFile: string): EvidenceBindingDiagnostic | undefined {
329
- const text = readWorkspaceText(workspaceRoot, findingsFile)
330
- if (text === undefined) return undefined
331
-
332
- const explicit = {
333
- source: hasSourceTrace(text),
334
- quoteOrSnippet: hasQuoteOrSnippet(text),
335
- supportScope: hasField(text, ["support scope", "supported scope", "supports", "support"]),
336
- unsupportedScope: hasField(text, ["unsupported scope", "unsupported", "not supported", "gaps"]),
337
- caveat: hasField(text, ["caveat", "limitation", "limits", "boundary"]),
338
- strength: hasField(text, ["strength", "support strength", "evidence strength"]),
339
- }
340
- const failureReasons: EvidenceBindingFailureReason[] = []
341
- if (!explicit.quoteOrSnippet) failureReasons.push("missing_quote")
342
- if (!explicit.source) failureReasons.push("unclear_source")
343
- if (!explicit.supportScope || !explicit.unsupportedScope) failureReasons.push("unsupported_scope")
344
- if (!explicit.caveat) failureReasons.push("caveat_conflict")
345
- if (!explicit.strength) failureReasons.push("weak_source")
346
- if (looksContextOnly(text, explicit)) failureReasons.push("context_only_finding")
347
-
348
- return {
349
- findingsFile,
350
- bindable: failureReasons.length === 0,
351
- failureReasons,
352
- explicit,
353
- }
354
- }
355
-
356
- function readWorkspaceText(workspaceRoot: string | undefined, relativePath: string): string | undefined {
357
- if (!workspaceRoot) return undefined
358
- const root = resolve(workspaceRoot)
359
- const target = resolve(root, relativePath)
360
- if (target !== root && !target.startsWith(root + sep)) return undefined
361
- if (!existsSync(target)) return undefined
362
- return readFileSync(target, "utf-8")
363
- }
364
-
365
- function hasSourceTrace(text: string): boolean {
366
- return /^sources:\s*$/im.test(text)
367
- || /\[source:\s*[^\]]+\]/i.test(text)
368
- || /^\s*-?\s*source:\s*\S+/im.test(text)
369
- || /^\s*source\s+(path|url):\s*\S+/im.test(text)
370
- }
371
-
372
- function hasQuoteOrSnippet(text: string): boolean {
373
- return hasField(text, ["quote", "snippet"])
374
- || /["“][^"”]{20,}["”]/.test(text)
375
- || /^>\s*\S.{20,}/m.test(text)
376
- }
377
-
378
- function hasField(text: string, labels: string[]): boolean {
379
- return labels.some((label) => new RegExp(`(^|\\n)\\s*(?:[-*]\\s*)?${escapeRegex(label)}\\s*[::]\\s*\\S`, "i").test(text)
380
- || new RegExp(`^##+\\s+.*${escapeRegex(label)}`, "im").test(text))
381
- }
382
-
383
- function looksContextOnly(text: string, explicit: EvidenceBindingDiagnostic["explicit"]): boolean {
384
- return /^##+\s+data\b/im.test(text) && !explicit.quoteOrSnippet && (!explicit.supportScope || !explicit.caveat)
385
- }
386
-
387
- function escapeRegex(value: string): string {
388
- return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
389
- }
390
-
391
305
  function claimTarget(kind: Extract<ResearchTargetKind, "missing_evidence" | "weak_evidence" | "unsupported_scope">, claim: NarrativeClaim, priority: "high" | "medium", reason: string): ResearchTarget {
392
306
  return {
393
307
  id: `claim:${kind}:${claim.id}`,
@@ -0,0 +1,127 @@
1
+ export interface NarrativeVaultAuthoringContract {
2
+ role: string
3
+ standardSession: string[]
4
+ allowedActions: string[]
5
+ forbiddenCompatibilityActions: string[]
6
+ validNodeTypes: string[]
7
+ idConvention: {
8
+ rule: string
9
+ examples: string[]
10
+ avoid: string[]
11
+ }
12
+ relationSyntax: {
13
+ rule: string
14
+ examples: string[]
15
+ avoid: string[]
16
+ }
17
+ structuredTemplates: {
18
+ claim: Record<string, unknown>
19
+ evidence: Record<string, unknown>
20
+ researchGap: Record<string, unknown>
21
+ }
22
+ markdownRepairPolicy: string[]
23
+ }
24
+
25
+ export function narrativeVaultAuthoringContract(): NarrativeVaultAuthoringContract {
26
+ return {
27
+ role: "Markdown authoring guide. The LLM may maintain revela-narrative/**/*.md knowledge nodes directly; structured actions are optional safety helpers, not the primary authoring model.",
28
+ standardSession: [
29
+ "read(summary:true)",
30
+ "narrativeInventory before authoring ids or relations",
31
+ "edit Markdown nodes or use an optional targeted helper",
32
+ "markdown QA / compileNarrativeVault after authoring",
33
+ "report remaining blockers, evidence gaps, and unsupported scope",
34
+ ],
35
+ allowedActions: [
36
+ "initNarrativeVault",
37
+ "updateVaultCoreNarrative",
38
+ "upsertVaultClaim",
39
+ "upsertVaultEvidence",
40
+ "bindResearchFindings",
41
+ "upsertVaultObjection",
42
+ "upsertVaultRisk",
43
+ "upsertVaultResearchGap",
44
+ "updateVaultResearchGap",
45
+ "compileNarrativeVault",
46
+ ],
47
+ forbiddenCompatibilityActions: [
48
+ "upsertNarrative",
49
+ "upsertResearchGaps",
50
+ "deriveResearchGaps",
51
+ "updateResearchGap",
52
+ "closeResearchGap",
53
+ "applyEvidenceCandidates",
54
+ ],
55
+ validNodeTypes: ["index", "audience", "decision", "thesis", "claim", "evidence", "objection", "risk", "research-gap"],
56
+ idConvention: {
57
+ rule: "New vault nodes use plain stable ids without type prefixes or colons. Keep legacy ids when editing existing nodes, but do not generate new ids such as claim:foo.",
58
+ examples: ["claim-belief-change-purpose", "evidence-proposal-intent", "gap-market-size", "risk-overclaiming"],
59
+ avoid: ["claim:belief-change-purpose", "evidence:proposal:intent", "researchGap-market-size"],
60
+ },
61
+ relationSyntax: {
62
+ rule: "Relation lines are the primary graph source. Use a relation label and a plain node-id wikilink; frontmatter claimId/targetId/evidenceBindingIds are compatibility fallback only.",
63
+ examples: ["- supports: [[claim-belief-change-purpose]]", "- depends_on: [[evidence-proposal-intent]]"],
64
+ avoid: ["[[claim:claim-belief-change-purpose]]", "[[evidence:evidence-proposal-intent]]"],
65
+ },
66
+ structuredTemplates: {
67
+ claim: {
68
+ action: "upsertVaultClaim",
69
+ narrative: {
70
+ claims: [{
71
+ id: "claim-belief-change-purpose",
72
+ kind: "recommendation",
73
+ text: "Audience belief change is the central purpose of the artifact.",
74
+ importance: "central",
75
+ evidenceRequired: true,
76
+ evidenceStatus: "partial",
77
+ supportedScope: "What current sources explicitly support.",
78
+ unsupportedScope: "What still requires research or user confirmation.",
79
+ caveats: ["Do not overclaim beyond available source trace."],
80
+ }],
81
+ claimRelations: [{
82
+ fromClaimId: "claim-belief-change-purpose",
83
+ toClaimId: "claim-recommendation",
84
+ relation: "supports",
85
+ rationale: "Belief change frames the recommendation.",
86
+ }],
87
+ },
88
+ },
89
+ evidence: {
90
+ action: "upsertVaultEvidence",
91
+ evidence: {
92
+ id: "evidence-proposal-intent",
93
+ claimId: "claim-belief-change-purpose",
94
+ source: "proposal.md",
95
+ sourcePath: "proposal.md",
96
+ quote: "Exact quote or snippet from the source.",
97
+ location: "section or line reference when known",
98
+ supportScope: "Scope explicitly supported by the quote.",
99
+ unsupportedScope: "Scope not supported by this evidence.",
100
+ caveat: "Limitation that travels with this evidence.",
101
+ strength: "partial",
102
+ relationMarkdown: "## Relations\n\n- supports: [[claim-belief-change-purpose]]",
103
+ },
104
+ },
105
+ researchGap: {
106
+ action: "upsertVaultResearchGap",
107
+ gapId: "gap-market-size",
108
+ researchGaps: [{
109
+ id: "gap-market-size",
110
+ targetType: "claim",
111
+ targetId: "claim-market-size",
112
+ question: "What source can support the market-size claim?",
113
+ status: "open",
114
+ priority: "high",
115
+ createdFromIssueType: "missing_evidence",
116
+ relationMarkdown: "## Relations\n\n- depends_on: [[evidence-proposal-intent]]",
117
+ }],
118
+ },
119
+ },
120
+ markdownRepairPolicy: [
121
+ "Use direct Markdown patches only for small repairs after reading the existing node.",
122
+ "Do not delete and recreate existing vault nodes to fix schema.",
123
+ "Do not append a second frontmatter block.",
124
+ "Do not duplicate stable headings such as Evidence, Caveats, Relations, Response, Mitigation, or Notes.",
125
+ ],
126
+ }
127
+ }
@@ -0,0 +1,122 @@
1
+ import { existsSync, readFileSync } from "fs"
2
+ import { join } from "path"
3
+ import type { VaultDiagnosticDisplay } from "./diagnostic-report"
4
+
5
+ const VALID_TYPES = new Set(["index", "audience", "decision", "thesis", "claim", "evidence", "objection", "risk", "research-gap"])
6
+ const STABLE_HEADINGS = ["Evidence", "Caveats", "Relations", "Response", "Mitigation", "Notes"]
7
+
8
+ export interface VaultAuthoringGuardReport {
9
+ ok: boolean
10
+ blockers: VaultDiagnosticDisplay[]
11
+ warnings: VaultDiagnosticDisplay[]
12
+ }
13
+
14
+ export function runVaultAuthoringGuard(workspaceRoot: string, touched: string[]): VaultAuthoringGuardReport {
15
+ const diagnostics: VaultDiagnosticDisplay[] = []
16
+ for (const relativePath of [...new Set(touched)].sort()) {
17
+ const filePath = join(workspaceRoot, relativePath)
18
+ if (!existsSync(filePath)) continue
19
+ const text = readFileSync(filePath, "utf-8")
20
+ diagnostics.push(...inspectVaultMarkdown(relativePath.replace(/\\/g, "/"), text))
21
+ }
22
+
23
+ const blockers = diagnostics.filter((diagnostic) => diagnostic.severity === "error")
24
+ const warnings = diagnostics.filter((diagnostic) => diagnostic.severity === "warning")
25
+ return { ok: blockers.length === 0, blockers, warnings }
26
+ }
27
+
28
+ export function inspectVaultMarkdown(file: string, text: string): VaultDiagnosticDisplay[] {
29
+ const diagnostics: VaultDiagnosticDisplay[] = []
30
+ const type = extractFrontmatterField(text, "type")
31
+ const nodeId = extractFrontmatterField(text, "id")
32
+
33
+ if (countYamlFences(text) > 2) {
34
+ diagnostics.push(display({
35
+ severity: "error",
36
+ code: "duplicate_frontmatter",
37
+ file,
38
+ nodeId,
39
+ message: "Vault Markdown contains more than one frontmatter block, usually caused by appending a replacement document instead of replacing the old one.",
40
+ suggestedFix: "Keep one leading frontmatter block and merge the intended fields/body into the existing document.",
41
+ suggestedAction: "Read the file, remove the duplicated frontmatter/body, and rerun compileNarrativeVault.",
42
+ }))
43
+ }
44
+
45
+ if (type && !VALID_TYPES.has(type)) {
46
+ diagnostics.push(display({
47
+ severity: "error",
48
+ code: "invalid_node_type_authoring",
49
+ file,
50
+ nodeId,
51
+ message: `Unsupported vault node type \`${type}\` in frontmatter.`,
52
+ suggestedFix: type === "researchGap" || type === "research_gap"
53
+ ? "Use `type: \"research-gap\"` for research gap nodes."
54
+ : "Use a supported node type: index, audience, decision, thesis, claim, evidence, objection, risk, or research-gap.",
55
+ suggestedAction: "Patch only the type frontmatter line and rerun compileNarrativeVault.",
56
+ }))
57
+ }
58
+
59
+ if (type === "evidence" && !extractFrontmatterField(text, "claimId")) {
60
+ diagnostics.push(display({
61
+ severity: "error",
62
+ code: "evidence_claim_id_missing_authoring",
63
+ file,
64
+ nodeId,
65
+ message: "Evidence nodes must declare claimId before they can become canonical support.",
66
+ suggestedFix: "Add `claimId` pointing to an existing claim id, or keep the material as research/findings until support is explicit.",
67
+ suggestedAction: "Patch the evidence frontmatter and rerun compileNarrativeVault.",
68
+ }))
69
+ }
70
+
71
+ for (const heading of STABLE_HEADINGS) {
72
+ const count = countHeading(text, heading)
73
+ if (count > 1) {
74
+ diagnostics.push(display({
75
+ severity: "error",
76
+ code: "duplicate_stable_heading",
77
+ file,
78
+ nodeId,
79
+ message: `Vault Markdown contains ${count} \`## ${heading}\` sections.`,
80
+ suggestedFix: `Merge content into a single \`## ${heading}\` section instead of appending a duplicate heading.`,
81
+ suggestedAction: "Read the file, merge duplicate sections in place, and rerun compileNarrativeVault.",
82
+ }))
83
+ }
84
+ }
85
+
86
+ for (const match of text.matchAll(/\[\[([A-Za-z][\w-]*):([^\]]+)\]\]/g)) {
87
+ if (!match[2].startsWith(`${match[1]}-`) && !match[2].startsWith(`${match[1]}:`)) continue
88
+ diagnostics.push(display({
89
+ severity: "error",
90
+ code: "typed_wikilink_target",
91
+ file,
92
+ nodeId,
93
+ message: `Relation target \`[[${match[1]}:${match[2]}]]\` mixes type metadata into the wikilink id.`,
94
+ suggestedFix: `Use standard node-id wikilinks, for example \`[[${match[2]}]]\`, and keep relation type in the list prefix such as \`supports:\`.`,
95
+ suggestedAction: "Patch the Relations wikilinks to target node ids directly and rerun compileNarrativeVault.",
96
+ }))
97
+ }
98
+
99
+ return diagnostics
100
+ }
101
+
102
+ function display(diagnostic: VaultDiagnosticDisplay): VaultDiagnosticDisplay {
103
+ return diagnostic
104
+ }
105
+
106
+ function countYamlFences(text: string): number {
107
+ return text.split(/\r?\n/).filter((line) => line.trim() === "---").length
108
+ }
109
+
110
+ function countHeading(text: string, heading: string): number {
111
+ const escaped = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
112
+ const pattern = new RegExp(`^##\\s+${escaped}\\s*$`, "gim")
113
+ return [...text.matchAll(pattern)].length
114
+ }
115
+
116
+ function extractFrontmatterField(text: string, field: string): string | undefined {
117
+ const frontmatter = text.match(/^---\s*\r?\n([\s\S]*?)\r?\n---/)
118
+ if (!frontmatter) return undefined
119
+ const escaped = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
120
+ const match = frontmatter[1].match(new RegExp(`^${escaped}:\\s*["']?([^"'\\r\\n]+)["']?\\s*$`, "m"))
121
+ return match?.[1]?.trim()
122
+ }