@cyber-dash-tech/revela 0.17.18 → 0.17.21

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.
@@ -22,7 +22,7 @@ import {
22
22
  statSync,
23
23
  writeFileSync,
24
24
  } from "fs"
25
- import { join, resolve, basename } from "path"
25
+ import { dirname, join, resolve, basename } from "path"
26
26
  import { tmpdir } from "os"
27
27
  import { parseFrontmatter } from "../frontmatter"
28
28
  import {
@@ -49,6 +49,44 @@ export interface DomainInfo {
49
49
  skillText: string
50
50
  }
51
51
 
52
+ export interface CreateDomainPackageArgs {
53
+ name: string
54
+ domainMd: string
55
+ overwrite?: boolean
56
+ }
57
+
58
+ export interface CreateDomainDraftArgs extends CreateDomainPackageArgs {
59
+ workspaceRoot: string
60
+ }
61
+
62
+ export interface CreateDomainPackageResult {
63
+ ok: true
64
+ name: string
65
+ path: string
66
+ files: string[]
67
+ overwritten: boolean
68
+ }
69
+
70
+ export interface InstallDomainDraftArgs {
71
+ workspaceRoot: string
72
+ name: string
73
+ overwrite?: boolean
74
+ }
75
+
76
+ export interface InstallDomainDraftResult extends CreateDomainPackageResult {
77
+ sourcePath: string
78
+ }
79
+
80
+ export interface ValidateDomainPackageResult {
81
+ ok: boolean
82
+ name: string
83
+ path: string
84
+ hasIndustryMd: boolean
85
+ hasRequiredFrontmatter: boolean
86
+ hasBody: boolean
87
+ errors: string[]
88
+ }
89
+
52
90
  // ---------------------------------------------------------------------------
53
91
  // Seed
54
92
  // ---------------------------------------------------------------------------
@@ -148,6 +186,188 @@ export function getDomainSkillMd(name?: string): string {
148
186
  return info.skillText
149
187
  }
150
188
 
189
+ /** Normalize and validate a domain package name. */
190
+ export function normalizeDomainName(name: string): string {
191
+ const normalized = name.trim().toLowerCase()
192
+ if (!/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/.test(normalized)) {
193
+ throw new Error("Domain name must be kebab-case using lowercase letters, numbers, and hyphens")
194
+ }
195
+ return normalized
196
+ }
197
+
198
+ /** Create a local domain package in ~/.config/revela/domains/<name>/. */
199
+ export function createDomainPackage(args: CreateDomainPackageArgs): CreateDomainPackageResult {
200
+ const name = normalizeDomainName(args.name)
201
+ const domainMd = args.domainMd?.trim()
202
+
203
+ if (!domainMd) throw new Error("domainMd is required")
204
+
205
+ const target = join(DOMAINS_DIR, name)
206
+ const existed = existsSync(target)
207
+ if (existed && !args.overwrite) {
208
+ throw new Error(`Domain '${name}' already exists. Pass overwrite=true to replace it.`)
209
+ }
210
+
211
+ mkdirSync(DOMAINS_DIR, { recursive: true })
212
+ if (existed) {
213
+ rmSync(target, { recursive: true, force: true })
214
+ }
215
+ mkdirSync(target, { recursive: true })
216
+ writeFileSync(join(target, DOMAIN_FILE), `${domainMd}\n`, "utf-8")
217
+
218
+ const validation = validateDomainPackage(name)
219
+ if (!validation.ok) {
220
+ throw new Error(`Created domain package is invalid: ${validation.errors.join("; ")}`)
221
+ }
222
+
223
+ return {
224
+ ok: true,
225
+ name,
226
+ path: target,
227
+ files: [DOMAIN_FILE],
228
+ overwritten: existed,
229
+ }
230
+ }
231
+
232
+ /** Create a project-local domain draft under .revela/drafts/domains/<name>/. */
233
+ export function createDomainDraftPackage(args: CreateDomainDraftArgs): CreateDomainPackageResult {
234
+ const name = normalizeDomainName(args.name)
235
+ const domainMd = args.domainMd?.trim()
236
+
237
+ if (!domainMd) throw new Error("domainMd is required")
238
+
239
+ const target = domainDraftDir(args.workspaceRoot, name)
240
+ const existed = existsSync(target)
241
+ if (existed && !args.overwrite) {
242
+ throw new Error(`Domain draft '${name}' already exists. Pass overwrite=true to replace it.`)
243
+ }
244
+
245
+ mkdirSync(dirname(target), { recursive: true })
246
+ if (existed) {
247
+ rmSync(target, { recursive: true, force: true })
248
+ }
249
+ mkdirSync(target, { recursive: true })
250
+ writeFileSync(join(target, DOMAIN_FILE), `${domainMd}\n`, "utf-8")
251
+
252
+ const validation = validateDomainDraftPackage(args.workspaceRoot, name)
253
+ if (!validation.ok) {
254
+ throw new Error(`Created domain draft is invalid: ${validation.errors.join("; ")}`)
255
+ }
256
+
257
+ return {
258
+ ok: true,
259
+ name,
260
+ path: target,
261
+ files: [DOMAIN_FILE],
262
+ overwritten: existed,
263
+ }
264
+ }
265
+
266
+ /** Validate a project-local domain draft. */
267
+ export function validateDomainDraftPackage(workspaceRoot: string, nameInput: string): ValidateDomainPackageResult {
268
+ let name = nameInput
269
+ try {
270
+ name = normalizeDomainName(nameInput)
271
+ } catch {
272
+ // validateDomainPackageAt records the invalid-name error.
273
+ }
274
+ return validateDomainPackageAt(nameInput, domainDraftDir(workspaceRoot, name))
275
+ }
276
+
277
+ /** Install a validated project-local domain draft into the user-level domain registry. */
278
+ export function installDomainDraftPackage(args: InstallDomainDraftArgs): InstallDomainDraftResult {
279
+ const name = normalizeDomainName(args.name)
280
+ const sourcePath = domainDraftDir(args.workspaceRoot, name)
281
+ const validation = validateDomainDraftPackage(args.workspaceRoot, name)
282
+ if (!validation.ok) {
283
+ throw new Error(`Domain draft is invalid: ${validation.errors.join("; ")}`)
284
+ }
285
+
286
+ const target = join(DOMAINS_DIR, name)
287
+ const existed = existsSync(target)
288
+ if (existed && !args.overwrite) {
289
+ throw new Error(`Domain '${name}' already exists. Pass overwrite=true to replace it.`)
290
+ }
291
+
292
+ try {
293
+ mkdirSync(DOMAINS_DIR, { recursive: true })
294
+ if (existed) {
295
+ rmSync(target, { recursive: true, force: true })
296
+ }
297
+ cpSync(sourcePath, target, { recursive: true })
298
+ } catch (e) {
299
+ throw new Error(`Installing domain draft requires write access to Revela user config at ${DOMAINS_DIR}: ${e instanceof Error ? e.message : String(e)}`)
300
+ }
301
+
302
+ return {
303
+ ok: true,
304
+ name,
305
+ path: target,
306
+ sourcePath,
307
+ files: [DOMAIN_FILE],
308
+ overwritten: existed,
309
+ }
310
+ }
311
+
312
+ /** Validate a local domain package for the minimum Revela domain contract. */
313
+ export function validateDomainPackage(nameInput: string): ValidateDomainPackageResult {
314
+ let name = nameInput
315
+ try {
316
+ name = normalizeDomainName(nameInput)
317
+ } catch {
318
+ // validateDomainPackageAt records the invalid-name error.
319
+ }
320
+ return validateDomainPackageAt(nameInput, join(DOMAINS_DIR, name))
321
+ }
322
+
323
+ function validateDomainPackageAt(nameInput: string, dir: string): ValidateDomainPackageResult {
324
+ let name = nameInput
325
+ const errors: string[] = []
326
+ try {
327
+ name = normalizeDomainName(nameInput)
328
+ } catch (e) {
329
+ errors.push(e instanceof Error ? e.message : String(e))
330
+ }
331
+
332
+ const mdPath = join(dir, DOMAIN_FILE)
333
+ const hasIndustryMd = existsSync(mdPath)
334
+ let hasRequiredFrontmatter = false
335
+ let hasBody = false
336
+
337
+ if (!existsSync(dir)) errors.push(`Domain directory does not exist: ${dir}`)
338
+ if (!hasIndustryMd) errors.push(`${DOMAIN_FILE} is missing`)
339
+
340
+ if (hasIndustryMd) {
341
+ try {
342
+ const text = readFileSync(mdPath, "utf-8")
343
+ const { meta, body } = parseFrontmatter(text)
344
+ const required = ["name", "description", "author", "version"]
345
+ const missing = required.filter((field) => !meta[field]?.trim())
346
+ hasRequiredFrontmatter = missing.length === 0
347
+ hasBody = body.trim().length > 0
348
+ if (!hasRequiredFrontmatter) errors.push(`INDUSTRY.md frontmatter is missing required field(s): ${missing.join(", ")}`)
349
+ if (!hasBody) errors.push("INDUSTRY.md body/guidance is empty")
350
+ if (meta.name && meta.name.trim() !== name) errors.push(`INDUSTRY.md frontmatter name '${meta.name.trim()}' does not match package name '${name}'`)
351
+ } catch (e) {
352
+ errors.push(`INDUSTRY.md could not be parsed: ${e instanceof Error ? e.message : String(e)}`)
353
+ }
354
+ }
355
+
356
+ return {
357
+ ok: errors.length === 0,
358
+ name,
359
+ path: dir,
360
+ hasIndustryMd,
361
+ hasRequiredFrontmatter,
362
+ hasBody,
363
+ errors,
364
+ }
365
+ }
366
+
367
+ function domainDraftDir(workspaceRoot: string, name: string): string {
368
+ return resolve(workspaceRoot, ".revela", "drafts", "domains", name)
369
+ }
370
+
151
371
  /** Remove an installed domain. Throws if not found or is the protected default. */
152
372
  export function removeDomain(name: string): void {
153
373
  if (name === DEFAULT_DOMAIN) {
package/lib/qa/checks.ts CHANGED
@@ -209,19 +209,16 @@ function checkCanvas(metrics: SlideMetrics): LayoutIssue[] {
209
209
  }
210
210
 
211
211
  const canvasBad = Math.abs(metrics.canvasRect.width - CANVAS_W) > tol || Math.abs(metrics.canvasRect.height - CANVAS_H) > tol
212
- const slideBad = Math.abs(metrics.slideRect.width - CANVAS_W) > tol || Math.abs(metrics.slideRect.height - CANVAS_H) > tol
213
212
 
214
- if (canvasBad || slideBad) {
213
+ if (canvasBad) {
215
214
  issues.push({
216
215
  type: "canvas",
217
216
  sub: "size_mismatch",
218
217
  severity: "error",
219
- detail: `Slide and canvas must render exactly ${CANVAS_W}x${CANVAS_H}px. Measured slide ${Math.round(metrics.slideRect.width)}x${Math.round(metrics.slideRect.height)}px, canvas ${Math.round(metrics.canvasRect.width)}x${Math.round(metrics.canvasRect.height)}px.`,
218
+ detail: `.slide-canvas must render exactly ${CANVAS_W}x${CANVAS_H}px. Measured canvas ${Math.round(metrics.canvasRect.width)}x${Math.round(metrics.canvasRect.height)}px.`,
220
219
  data: {
221
220
  expectedWidth: CANVAS_W,
222
221
  expectedHeight: CANVAS_H,
223
- slideWidth: Math.round(metrics.slideRect.width),
224
- slideHeight: Math.round(metrics.slideRect.height),
225
222
  canvasWidth: Math.round(metrics.canvasRect.width),
226
223
  canvasHeight: Math.round(metrics.canvasRect.height),
227
224
  },
@@ -911,7 +908,7 @@ export function formatReport(report: QAReport): string {
911
908
  `### Action Required`,
912
909
  ``,
913
910
  `Please fix the above hard-error issues in the HTML file. For each issue type:`,
914
- `- **canvas**: ensure each slide and .slide-canvas render exactly 1920x1080px, not merely any 16:9 size.`,
911
+ `- **canvas**: ensure each canonical .slide has exactly one direct .slide-canvas and that .slide-canvas renders exactly 1920x1080px, not merely any 16:9 size.`,
915
912
  `- **scrollbar**: remove horizontal document/body scrolling and slide-internal scrolling. Multi-slide decks may use normal vertical document scroll for navigation.`,
916
913
  `- **navigation**: keep every .slide in normal document flow; do not stack slides with fixed/absolute positioning or rely on aria-hidden/visibility toggles for pagination. Use scrollIntoView-based navigation.`,
917
914
  `- **overflow**: reduce font size, padding, or content amount for the affected element.`,
@@ -4,18 +4,24 @@ import { dirname, resolve } from "path"
4
4
  import {
5
5
  activeDesign,
6
6
  activateDesign,
7
+ createDesignDraftPackage,
7
8
  createDesignPackage,
8
9
  getDesignSection,
9
10
  getDesignSkillMd,
11
+ installDesignDraftPackage,
10
12
  listDesigns,
11
13
  seedBuiltinDesigns,
14
+ validateDesignDraftPackage,
12
15
  validateDesignPackage,
13
16
  } from "../design/designs"
14
17
  import { createDeckFoundation as createDeckFoundationShell } from "../deck-html/foundation"
15
- import { activeDomain, activateDomain, getDomainSkillMd, listDomains, seedBuiltinDomains } from "../domain/domains"
18
+ import { activeDomain, activateDomain, createDomainDraftPackage, createDomainPackage, getDomainSkillMd, installDomainDraftPackage, listDomains, seedBuiltinDomains, validateDomainDraftPackage, validateDomainPackage } from "../domain/domains"
16
19
  import { computeNarrativeHash } from "../narrative-state/hash"
17
20
  import { compileNarrativeVault } from "../narrative-vault/compile"
21
+ import { autoCompileNarrativeVault } from "../narrative-vault/auto-compile"
22
+ import { extractNarrativeVaultMarkdownTargetsFromPatch } from "../narrative-vault/hook-targets"
18
23
  import { runNarrativeMarkdownQa, type MarkdownQaOptions } from "../narrative-vault/markdown-qa"
24
+ import { formatArtifactQaUserNotice, formatMarkdownQaUserNotice } from "../hook-notifications"
19
25
  import { readDeckPlanArtifact } from "../narrative-state/deck-plan-artifact"
20
26
  import { extractDesignClasses } from "../design/designs"
21
27
  import { recordRenderedArtifact, workspaceRelative } from "../workspace-state/rendered-artifacts"
@@ -32,6 +38,10 @@ export interface RuntimeFileInput extends RuntimeWorkspaceInput {
32
38
  file: string
33
39
  }
34
40
 
41
+ export interface RuntimeNarrativeAutoCompileInput extends RuntimeWorkspaceInput {
42
+ touched?: string[]
43
+ }
44
+
35
45
  export interface RuntimeDeckFoundationInput extends RuntimeWorkspaceInput {
36
46
  outputPath: string
37
47
  title: string
@@ -55,12 +65,28 @@ export interface RuntimeDesignCreateInput {
55
65
  overwrite?: boolean
56
66
  }
57
67
 
68
+ export interface RuntimeDesignDraftCreateInput extends RuntimeDesignCreateInput, RuntimeWorkspaceInput {}
69
+
70
+ export interface RuntimeDomainCreateInput {
71
+ name: string
72
+ domainMd: string
73
+ overwrite?: boolean
74
+ }
75
+
76
+ export interface RuntimeDomainDraftCreateInput extends RuntimeDomainCreateInput, RuntimeWorkspaceInput {}
77
+
78
+ export interface RuntimeDraftInstallInput extends RuntimeWorkspaceInput {
79
+ name: string
80
+ overwrite?: boolean
81
+ }
82
+
58
83
  export interface RuntimeNameInput {
59
84
  name: string
60
85
  }
61
86
 
62
87
  export function doctor(input: RuntimeWorkspaceInput = {}) {
63
88
  const workspaceRoot = root(input.workspaceRoot)
89
+ const domain = activeDomainDoctorInfo()
64
90
  return {
65
91
  ok: true,
66
92
  version: pkg.version,
@@ -69,6 +95,8 @@ export function doctor(input: RuntimeWorkspaceInput = {}) {
69
95
  hasDeckPlan: existsSync(resolve(workspaceRoot, "deck-plan")),
70
96
  hasDecksJson: existsSync(resolve(workspaceRoot, "DECKS.json")),
71
97
  activeDesign: safe(activeDesign),
98
+ activeDomain: domain.name,
99
+ activeDomainDescription: domain.description,
72
100
  }
73
101
  }
74
102
 
@@ -86,6 +114,18 @@ export function markdownQa(input: RuntimeWorkspaceInput & MarkdownQaOptions = {}
86
114
  })
87
115
  }
88
116
 
117
+ export function autoCompileNarrative(input: RuntimeNarrativeAutoCompileInput = {}) {
118
+ const workspaceRoot = root(input.workspaceRoot)
119
+ return autoCompileNarrativeVault(workspaceRoot, input.touched ?? [])
120
+ }
121
+
122
+ export function extractNarrativeVaultMarkdownPatchTargets(input: RuntimeWorkspaceInput & { patch: string }) {
123
+ const workspaceRoot = root(input.workspaceRoot)
124
+ return extractNarrativeVaultMarkdownTargetsFromPatch(input.patch, workspaceRoot)
125
+ }
126
+
127
+ export { formatArtifactQaUserNotice, formatMarkdownQaUserNotice }
128
+
89
129
  export function readDeckPlan(input: RuntimeWorkspaceInput = {}) {
90
130
  const workspaceRoot = root(input.workspaceRoot)
91
131
  const compiled = compileNarrativeVault(workspaceRoot)
@@ -220,6 +260,30 @@ export function designValidate(input: RuntimeNameInput) {
220
260
  return validateDesignPackage(requiredName(input, "design"))
221
261
  }
222
262
 
263
+ export function designDraftCreate(input: RuntimeDesignDraftCreateInput) {
264
+ return createDesignDraftPackage({
265
+ workspaceRoot: root(input.workspaceRoot),
266
+ name: requiredString(input?.name, "design name"),
267
+ base: input.base,
268
+ designMd: requiredString(input?.designMd, "designMd"),
269
+ previewHtml: requiredString(input?.previewHtml, "previewHtml"),
270
+ overwrite: input.overwrite ?? false,
271
+ })
272
+ }
273
+
274
+ export function designDraftValidate(input: RuntimeWorkspaceInput & RuntimeNameInput) {
275
+ return validateDesignDraftPackage(root(input.workspaceRoot), requiredName(input, "design draft"))
276
+ }
277
+
278
+ export function designDraftInstall(input: RuntimeDraftInstallInput) {
279
+ seedBuiltinDesigns()
280
+ return installDesignDraftPackage({
281
+ workspaceRoot: root(input.workspaceRoot),
282
+ name: requiredName(input, "design draft"),
283
+ overwrite: input.overwrite ?? false,
284
+ })
285
+ }
286
+
223
287
  export interface DesignRulesReadinessResult {
224
288
  ok: boolean
225
289
  activeDesign: string
@@ -322,6 +386,42 @@ export function domainActivate(input: RuntimeNameInput) {
322
386
  }
323
387
  }
324
388
 
389
+ export function domainCreate(input: RuntimeDomainCreateInput) {
390
+ seedBuiltinDomains()
391
+ return createDomainPackage({
392
+ name: requiredString(input?.name, "domain name"),
393
+ domainMd: requiredString(input?.domainMd, "domainMd"),
394
+ overwrite: input.overwrite ?? false,
395
+ })
396
+ }
397
+
398
+ export function domainValidate(input: RuntimeNameInput) {
399
+ seedBuiltinDomains()
400
+ return validateDomainPackage(requiredName(input, "domain"))
401
+ }
402
+
403
+ export function domainDraftCreate(input: RuntimeDomainDraftCreateInput) {
404
+ return createDomainDraftPackage({
405
+ workspaceRoot: root(input.workspaceRoot),
406
+ name: requiredString(input?.name, "domain name"),
407
+ domainMd: requiredString(input?.domainMd, "domainMd"),
408
+ overwrite: input.overwrite ?? false,
409
+ })
410
+ }
411
+
412
+ export function domainDraftValidate(input: RuntimeWorkspaceInput & RuntimeNameInput) {
413
+ return validateDomainDraftPackage(root(input.workspaceRoot), requiredName(input, "domain draft"))
414
+ }
415
+
416
+ export function domainDraftInstall(input: RuntimeDraftInstallInput) {
417
+ seedBuiltinDomains()
418
+ return installDomainDraftPackage({
419
+ workspaceRoot: root(input.workspaceRoot),
420
+ name: requiredName(input, "domain draft"),
421
+ overwrite: input.overwrite ?? false,
422
+ })
423
+ }
424
+
325
425
  function root(workspaceRoot: string | undefined): string {
326
426
  return resolve(workspaceRoot || process.cwd())
327
427
  }
@@ -334,6 +434,17 @@ function safe<T>(fn: () => T): T | undefined {
334
434
  }
335
435
  }
336
436
 
437
+ function activeDomainDoctorInfo(): { name: string; description: string } {
438
+ try {
439
+ seedBuiltinDomains()
440
+ const name = activeDomain()
441
+ const description = listDomains().find((domain) => domain.name === name)?.description ?? ""
442
+ return { name, description }
443
+ } catch {
444
+ return { name: safe(activeDomain) ?? "", description: "" }
445
+ }
446
+ }
447
+
337
448
  function requiredName(input: RuntimeNameInput, label: string): string {
338
449
  const name = input?.name?.trim()
339
450
  if (!name) throw new Error(`${label} name is required`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cyber-dash-tech/revela",
3
- "version": "0.17.18",
3
+ "version": "0.17.21",
4
4
  "description": "OpenCode plugin for trusted narrative artifacts from local sources, research, and evidence",
5
5
  "type": "module",
6
6
  "main": "./index.ts",
@@ -2,7 +2,7 @@
2
2
  "mcpServers": {
3
3
  "revela": {
4
4
  "command": "npx",
5
- "args": ["-y", "@cyber-dash-tech/revela@0.17.18", "mcp"]
5
+ "args": ["-y", "@cyber-dash-tech/revela@0.17.21", "mcp"]
6
6
  }
7
7
  }
8
8
  }
@@ -17,6 +17,15 @@ export async function runPreWriteChecks(input: string): Promise<HookResult> {
17
17
  messages.push(`Revela controls ${controlledStateFile}. Use Revela MCP/runtime tools or file-native narrative files instead of direct ${controlledStateFile} patches.`)
18
18
  }
19
19
 
20
+ const cacheTargets = extractNarrativeCachePatchTargets(input)
21
+ if (cacheTargets.length > 0) {
22
+ messages.push([
23
+ "Revela narrative cache patches are blocked.",
24
+ `Controlled cache target(s): ${cacheTargets.map((target) => `\`${target}\``).join(", ")}`,
25
+ "Edit `revela-narrative/**/*.md` instead; compile/cache files under `.opencode/revela/narrative-cache/` are regenerated.",
26
+ ].join("\n"))
27
+ }
28
+
20
29
  const deckTargets = extractDeckHtmlPatchTargets(input)
21
30
  if (deckTargets.length > 0) {
22
31
  const pluginRoot = resolve(process.env.PLUGIN_ROOT || dirname(dirname(fileURLToPath(import.meta.url))))
@@ -55,6 +64,16 @@ export function extractDeckHtmlPatchTargets(input: string): string[] {
55
64
  return [...targets].sort((a, b) => a.localeCompare(b))
56
65
  }
57
66
 
67
+ export function extractNarrativeCachePatchTargets(input: string): string[] {
68
+ const targets = new Set<string>()
69
+ for (const patch of patchPayloads(input)) {
70
+ const pattern = /(?:^\*\*\* Update File: |^\*\*\* Add File: |^\*\*\* Delete File: |^\*\*\* Move to: )([^\r\n]*\.opencode\/revela\/narrative-cache\/[^\r\n]+)\s*$/gm
71
+ let match: RegExpExecArray | null
72
+ while ((match = pattern.exec(patch))) targets.add(match[1].trim())
73
+ }
74
+ return [...targets].sort((a, b) => a.localeCompare(b))
75
+ }
76
+
58
77
  function patchPayloads(input: string): string[] {
59
78
  try {
60
79
  const parsed = JSON.parse(input)
@@ -24,6 +24,20 @@ export function extractDeckHtmlTargets(input: string): string[] {
24
24
  return [...targets].sort((a, b) => a.localeCompare(b))
25
25
  }
26
26
 
27
+ export function patchPayloadsFromInput(input: string): string[] {
28
+ try {
29
+ const parsed = JSON.parse(input)
30
+ return [
31
+ parsed.patch,
32
+ parsed.args?.patch,
33
+ parsed.tool_input?.patch,
34
+ parsed.toolInput?.patch,
35
+ ].filter((item): item is string => typeof item === "string")
36
+ } catch {
37
+ return [input]
38
+ }
39
+ }
40
+
27
41
  export function workspaceRootFromInput(input: string): string {
28
42
  try {
29
43
  const parsed = JSON.parse(input)
@@ -47,18 +61,16 @@ export function workspaceRootFromInput(input: string): string {
47
61
 
48
62
  export async function runPostWriteChecks(input: string): Promise<HookResult> {
49
63
  const messages: string[] = []
50
- if (/revela-narrative\/.*\.md/.test(input)) {
51
- messages.push("Revela narrative Markdown changed. Run `revela_markdown_qa` and `revela_compile_narrative` before treating the graph as usable.")
52
- }
53
-
54
64
  const deckTargets = extractDeckHtmlTargets(input)
55
- if (deckTargets.length === 0) return { ok: true, messages }
65
+ const hasPossibleNarrativeMarkdown = /revela-narrative\/.*\.md/.test(input)
66
+ if (deckTargets.length === 0 && !hasPossibleNarrativeMarkdown) return { ok: true, messages }
56
67
 
57
68
  const pluginRoot = resolve(process.env.PLUGIN_ROOT || dirname(dirname(fileURLToPath(import.meta.url))))
58
69
  const runtime = resolveRevelaRuntime({ pluginRoot })
59
70
  if (!runtime.ok || !runtime.runtimePath) {
71
+ const changed = deckTargets.length > 0 ? "deck HTML changed" : "narrative Markdown changed"
60
72
  messages.push([
61
- "Revela deck HTML changed, but Codex hook could not locate the Revela runtime to run Artifact QA.",
73
+ `Revela ${changed}, but Codex hook could not locate the Revela runtime to run write-after checks.`,
62
74
  ...runtime.diagnostics.map((item) => `- ${item}`),
63
75
  ].join("\n"))
64
76
  return { ok: false, messages }
@@ -67,9 +79,28 @@ export async function runPostWriteChecks(input: string): Promise<HookResult> {
67
79
  const workspaceRoot = workspaceRootFromInput(input)
68
80
  const runtimeModule = await import(pathToFileURL(runtime.runtimePath).href)
69
81
  let ok = true
82
+
83
+ if (hasPossibleNarrativeMarkdown) {
84
+ const touched = new Set<string>()
85
+ for (const patch of patchPayloadsFromInput(input)) {
86
+ const targets = runtimeModule.extractNarrativeVaultMarkdownPatchTargets({ workspaceRoot, patch })
87
+ for (const target of targets) touched.add(target)
88
+ }
89
+
90
+ if (touched.size > 0) {
91
+ const result = runtimeModule.autoCompileNarrative({ workspaceRoot, touched: [...touched] })
92
+ messages.push(result.markdown ?? JSON.stringify(result, null, 2))
93
+ const notice = runtimeModule.formatMarkdownQaUserNotice?.(result)
94
+ if (notice) messages.push(notice)
95
+ if (!result.ok) ok = false
96
+ }
97
+ }
98
+
70
99
  for (const target of deckTargets) {
71
100
  const result = await runtimeModule.runDeckQa({ workspaceRoot, file: target })
72
101
  messages.push(result.markdown ?? JSON.stringify(result, null, 2))
102
+ const notice = runtimeModule.formatArtifactQaUserNotice?.(result.report)
103
+ if (notice) messages.push(notice)
73
104
  if (!result.ok) ok = false
74
105
  }
75
106