@cyber-dash-tech/revela 0.16.3 → 0.17.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -5
- package/README.zh-CN.md +7 -5
- package/lib/commands/brief.ts +9 -0
- package/lib/commands/help.ts +5 -2
- package/lib/commands/init.ts +42 -27
- package/lib/commands/narrative.ts +26 -2
- package/lib/commands/research.ts +36 -20
- package/lib/commands/review.ts +21 -18
- package/lib/ctx.ts +1 -1
- package/lib/decks-state.ts +38 -4
- package/lib/edit/prompt.ts +1 -1
- package/lib/hook-notifications.ts +53 -0
- package/lib/narrative-state/render-plan.ts +114 -27
- package/lib/narrative-state/research-binding-eval.ts +260 -0
- package/lib/narrative-state/research-gaps.ts +2 -88
- package/lib/narrative-vault/authoring-contract.ts +127 -0
- package/lib/narrative-vault/authoring-guard.ts +122 -0
- package/lib/narrative-vault/auto-compile.ts +134 -0
- package/lib/narrative-vault/bootstrap.ts +63 -0
- package/lib/narrative-vault/cache.ts +14 -0
- package/lib/narrative-vault/compile-mirror.ts +45 -0
- package/lib/narrative-vault/compile.ts +350 -0
- package/lib/narrative-vault/constants.ts +6 -0
- package/lib/narrative-vault/diagnostic-report.ts +117 -0
- package/lib/narrative-vault/export.ts +71 -0
- package/lib/narrative-vault/frontmatter.ts +41 -0
- package/lib/narrative-vault/hook-targets.ts +40 -0
- package/lib/narrative-vault/index.ts +18 -0
- package/lib/narrative-vault/inventory.ts +392 -0
- package/lib/narrative-vault/markdown-qa.ts +237 -0
- package/lib/narrative-vault/markdown.ts +34 -0
- package/lib/narrative-vault/migration.ts +52 -0
- package/lib/narrative-vault/mutate.ts +361 -0
- package/lib/narrative-vault/paths.ts +19 -0
- package/lib/narrative-vault/read.ts +52 -0
- package/lib/narrative-vault/relations.ts +32 -0
- package/lib/narrative-vault/source-loader.ts +19 -0
- package/lib/narrative-vault/timestamp.ts +32 -0
- package/lib/narrative-vault/types.ts +44 -0
- package/lib/refine/server.ts +472 -5
- package/lib/refine/visual-targets.ts +295 -0
- package/lib/source-materials.ts +98 -0
- package/lib/tool-result.ts +34 -0
- package/package.json +2 -2
- package/plugin.ts +60 -22
- package/skill/NARRATIVE_SKILL.md +25 -10
- package/tools/decks.ts +363 -67
- package/tools/research-save.ts +3 -0
- 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
|
+
}
|