import { basename, dirname, resolve } from "path" import { existsSync, readFileSync } from "fs" import { fileURLToPath, pathToFileURL } from "url" import { resolveRevelaRuntime } from "../mcp/runtime-resolver" interface HookResult { ok: boolean messages: string[] } export function extractDeckHtmlTargets(input: string): string[] { const targets = new Set() const pattern = /(?:^\*\*\* Update File: |^\*\*\* Add File: )((?:[^\r\n]*\/)?decks\/[^\s"'`<>*?\r\n]+\.html)\s*$/gm for (const patch of patchPayloadsFromInput(input)) { let match: RegExpExecArray | null while ((match = pattern.exec(patch))) { const target = match[1].trim() if (!target.includes("*") && !target.includes("?")) targets.add(target) } } return [...targets].sort((a, b) => a.localeCompare(b)) } export function patchPayloadsFromInput(input: string): string[] { try { const parsed = JSON.parse(input) return [ parsed.patch, parsed.args?.patch, parsed.tool_input?.patch, parsed.toolInput?.patch, ].filter((item): item is string => typeof item === "string") } catch { return [input] } } export function workspaceRootFromInput(input: string): string { try { const parsed = JSON.parse(input) const candidates = [ parsed.workspaceRoot, parsed.cwd, parsed.root, parsed.tool_input?.workspaceRoot, parsed.tool_input?.cwd, parsed.toolInput?.workspaceRoot, parsed.toolInput?.cwd, ] for (const candidate of candidates) { if (typeof candidate === "string" && candidate.trim()) return resolve(candidate) } } catch { // Hook payloads are not guaranteed to be JSON across Codex versions. } return resolve(process.env.CODEX_WORKSPACE_ROOT || process.env.PWD || process.cwd()) } function titleFromDeckHtml(absolutePath: string): string { try { if (!existsSync(absolutePath)) return basename(absolutePath) const html = readFileSync(absolutePath, "utf-8") const title = html.match(/]*>([^<]+)<\/title>/i)?.[1]?.trim() return title || basename(absolutePath) } catch { return basename(absolutePath) } } export function formatDeckWebsiteCardHandoffNotice(workspaceRoot: string, target: string): string { const absolutePath = resolve(workspaceRoot, target) const title = titleFromDeckHtml(absolutePath) const httpUrlTemplate = `http://127.0.0.1:/${target}` return [ "**Deck website card ready**", "", `Artifact QA passed for \`${target}\`. Do not open the deck file directly. Start a read-only local static server from the workspace root, then reply with this standalone deck link so Codex renders an Open in Browser website card the user can click:`, "", `[${title}](${httpUrlTemplate})`, "", `Replace \`\` with the actual localhost port. Keep \`file://\` only for non-Codex surfaces that allow direct local-file navigation.`, ].join("\n") } export async function runPostWriteChecks(input: string): Promise { const messages: string[] = [] const deckTargets = extractDeckHtmlTargets(input) const hasPossibleNarrativeMarkdown = /revela-narrative\/.*\.md/.test(input) if (deckTargets.length === 0 && !hasPossibleNarrativeMarkdown) return { ok: true, messages } const pluginRoot = resolve(process.env.PLUGIN_ROOT || dirname(dirname(fileURLToPath(import.meta.url)))) const runtime = resolveRevelaRuntime({ pluginRoot }) if (!runtime.ok || !runtime.runtimePath) { const changed = deckTargets.length > 0 ? "deck HTML changed" : "narrative Markdown changed" messages.push([ `Revela ${changed}, but Codex hook could not locate the Revela runtime to run write-after checks.`, ...runtime.diagnostics.map((item) => `- ${item}`), ].join("\n")) return { ok: false, messages } } const workspaceRoot = workspaceRootFromInput(input) const runtimeModule = await import(pathToFileURL(runtime.runtimePath).href) let ok = true if (hasPossibleNarrativeMarkdown) { const touched = new Set() for (const patch of patchPayloadsFromInput(input)) { const targets = runtimeModule.extractNarrativeVaultMarkdownPatchTargets({ workspaceRoot, patch }) for (const target of targets) touched.add(target) } if (touched.size > 0) { const result = runtimeModule.autoCompileNarrative({ workspaceRoot, touched: [...touched] }) messages.push(result.markdown ?? JSON.stringify(result, null, 2)) const notice = runtimeModule.formatMarkdownQaUserNotice?.(result) if (notice) messages.push(notice) if (!result.ok) ok = false } } for (const target of deckTargets) { if (!existsSync(resolve(workspaceRoot, target))) continue const result = await runtimeModule.runDeckQa({ workspaceRoot, file: target }) messages.push(result.markdown ?? JSON.stringify(result, null, 2)) const notice = runtimeModule.formatArtifactQaUserNotice?.(result.report) if (notice) messages.push(notice) if (result.ok) { messages.push(formatDeckWebsiteCardHandoffNotice(workspaceRoot, target)) } else { ok = false } } return { ok, messages } } if (import.meta.main) { const input = await new Response(Bun.stdin.stream()).text() try { const result = await runPostWriteChecks(input) if (result.messages.length > 0) console.error(result.messages.join("\n\n---\n\n")) process.exit(result.ok ? 0 : 2) } catch (e) { console.error("Revela post-write Artifact QA failed to run.") console.error(e instanceof Error ? e.message : String(e)) process.exit(2) } }