🦄 Unicorn.Land ▸ docs/superpowers/plans/2026-06-15-oannes-engine-phase-0-1.md
updated 2026-06-15

Oannes Content Engine — Phase 0 + Phase 1 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Stand up the Oannes content engine as a TypeScript/Node project (Phase 0: scaffold + a preflight that proves every external service is reachable), then build the first end-to-end vertical slice (Phase 1: pick a claim → write an attributed script → render a branded quote-card and carousel with Remotion → queue to a phone for approval → publish to TikTok via Blotato behind a DRY_RUN flag).

Architecture: A standalone Node/TS package at oannes-engine/. It reads the existing Oannes Obsidian vault (markdown + YAML frontmatter at oannes/Oannes/) directly — no DB needed for Phase 1. Each pipeline stage is a focused, independently testable module with an explicit interface; external services (Claude CLI, ElevenLabs, fal.ai, Blotato, Discord) are injected so units test without network. Remotion renders the still formats. DRY_RUN=true means generate + queue but never publish.

Tech Stack: Node 20 (native fetch), TypeScript (ESM), Vitest (tests), Zod (validate structured/LLM output), gray-matter (frontmatter), Remotion (@remotion/renderer + React/TSX still compositions), Claude CLI (claude --print) shelled out for scripting.


Prerequisites (human, before execution)

File structure (created across the plan)

oannes-engine/
  package.json                      # Task 1
  tsconfig.json                     # Task 1
  vitest.config.ts                  # Task 1
  src/
    config.ts                       # Task 2  — typed config from .env
    preflight.ts                    # Task 3  — service reachability checks
    cli.ts                          # Task 3 / 9 / 11 — command entrypoint
    graph/
      claims.ts                     # Task 4  — read claims from the vault
    curate/
      curator.ts                    # Task 5  — choose a claim → ContentBrief
      history.ts                    # Task 5  — track used claim ids
    script/
      scriptwriter.ts               # Task 6  — Claude → attributed copy
    render/
      QuoteCard.tsx                 # Task 7  — Remotion still composition
      Carousel.tsx                  # Task 8  — Remotion still composition
      Root.tsx                      # Task 7  — registers compositions
      render.ts                     # Task 7/8 — renderStill wrapper
    queue/
      approval.ts                   # Task 9  — write previews, approve/reject
      discord.ts                    # Task 9  — webhook preview (optional)
    publish/
      blotato.ts                    # Task 10 — post to TikTok (DRY_RUN aware)
    orchestrator.ts                 # Task 11 — run-once: curate→script→render→queue
    types.ts                        # Task 4  — shared types
  tests/
    fixtures/vault/Claims/*.md      # Task 4  — fixture claims
    *.test.ts

PHASE 0 — Scaffold + Preflight

Task 1: Initialize the Node/TypeScript project

Files: - Create: oannes-engine/package.json - Create: oannes-engine/tsconfig.json - Create: oannes-engine/vitest.config.ts

{
  "name": "oannes-engine",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "scripts": {
    "test": "vitest run",
    "test:watch": "vitest",
    "engine": "tsx src/cli.ts"
  },
  "dependencies": {
    "dotenv": "^16.4.5",
    "gray-matter": "^4.0.3",
    "zod": "^3.23.8",
    "remotion": "^4.0.0",
    "@remotion/renderer": "^4.0.0",
    "@remotion/bundler": "^4.0.0",
    "react": "^18.3.1",
    "react-dom": "^18.3.1"
  },
  "devDependencies": {
    "typescript": "^5.5.4",
    "tsx": "^4.16.2",
    "vitest": "^2.0.5",
    "@types/node": "^20.14.0",
    "@types/react": "^18.3.3"
  }
}
{
  "compilerOptions": {
    "target": "ES2022",
    "module": "ESNext",
    "moduleResolution": "Bundler",
    "jsx": "react-jsx",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "resolveJsonModule": true,
    "types": ["node", "vitest/globals"]
  },
  "include": ["src", "tests"]
}
import { defineConfig } from "vitest/config";

export default defineConfig({
  test: { globals: true, environment: "node" },
});

Run: cd oannes-engine && npm install Expected: completes, creates node_modules/ and package-lock.json.

cd oannes-engine && git add package.json package-lock.json tsconfig.json vitest.config.ts
git commit -m "chore(oannes-engine): scaffold Node/TS project"

Task 2: Typed config from .env

Files: - Create: oannes-engine/src/config.ts - Test: oannes-engine/tests/config.test.ts

// tests/config.test.ts
import { loadConfig } from "../src/config.js";

test("loadConfig reads vault dir and detects which services are enabled", () => {
  const cfg = loadConfig({
    VAULT_DIR: "/tmp/vault",
    ELEVENLABS_API_KEY: "el-123",
    FAL_KEY: "",
    BLOTATO_API_KEY: "bl-456",
    DRY_RUN: "true",
    POSTS_PER_DAY: "2",
  });
  expect(cfg.vaultDir).toBe("/tmp/vault");
  expect(cfg.dryRun).toBe(true);
  expect(cfg.postsPerDay).toBe(2);
  expect(cfg.services.elevenlabs).toBe(true);
  expect(cfg.services.fal).toBe(false); // empty key = disabled
  expect(cfg.services.blotato).toBe(true);
});

test("loadConfig defaults vaultDir to the sibling Oannes vault", () => {
  const cfg = loadConfig({});
  expect(cfg.vaultDir).toContain("oannes/Oannes");
  expect(cfg.dryRun).toBe(true); // safe default
});

Run: cd oannes-engine && npx vitest run tests/config.test.ts Expected: FAIL — cannot find module ../src/config.js.

// src/config.ts
import * as path from "node:path";
import { fileURLToPath } from "node:url";

const here = path.dirname(fileURLToPath(import.meta.url));
const DEFAULT_VAULT = path.resolve(here, "../../oannes/Oannes");

export interface Config {
  vaultDir: string;
  dryRun: boolean;
  postsPerDay: number;
  renderOutputDir: string;
  keys: {
    elevenlabs?: string;
    fal?: string;
    blotato?: string;
    discordWebhook?: string;
  };
  services: { elevenlabs: boolean; fal: boolean; blotato: boolean };
}

function present(v?: string): boolean {
  return typeof v === "string" && v.trim().length > 0;
}

export function loadConfig(env: Record<string, string | undefined> = process.env): Config {
  return {
    vaultDir: present(env.VAULT_DIR) ? env.VAULT_DIR! : DEFAULT_VAULT,
    dryRun: env.DRY_RUN !== "false", // default true unless explicitly "false"
    postsPerDay: Number(env.POSTS_PER_DAY ?? "2"),
    renderOutputDir: present(env.RENDER_OUTPUT_DIR) ? env.RENDER_OUTPUT_DIR! : "./renders",
    keys: {
      elevenlabs: env.ELEVENLABS_API_KEY,
      fal: env.FAL_KEY,
      blotato: env.BLOTATO_API_KEY,
      discordWebhook: env.DISCORD_WEBHOOK_URL,
    },
    services: {
      elevenlabs: present(env.ELEVENLABS_API_KEY),
      fal: present(env.FAL_KEY),
      blotato: present(env.BLOTATO_API_KEY),
    },
  };
}

Run: cd oannes-engine && npx vitest run tests/config.test.ts Expected: PASS (2 tests).

cd oannes-engine && git add src/config.ts tests/config.test.ts
git commit -m "feat(oannes-engine): typed config with service detection"

Task 3: Preflight — prove every configured service is reachable

Files: - Create: oannes-engine/src/preflight.ts - Create: oannes-engine/src/cli.ts - Test: oannes-engine/tests/preflight.test.ts

Each check is a pure function taking an injected fetch so tests never hit the network. A service with no key returns { status: "skipped" }.

// tests/preflight.test.ts
import { checkElevenLabs, checkBlotato, checkVault } from "../src/preflight.js";

const ok = async () => ({ ok: true, status: 200, json: async () => ({}) }) as unknown as Response;
const fail = async () => ({ ok: false, status: 401, json: async () => ({}) }) as unknown as Response;

test("checkElevenLabs skips when no key", async () => {
  const r = await checkElevenLabs(undefined, ok);
  expect(r.status).toBe("skipped");
});

test("checkElevenLabs ok with key + 200", async () => {
  const r = await checkElevenLabs("el-123", ok);
  expect(r.status).toBe("ok");
});

test("checkElevenLabs error on 401", async () => {
  const r = await checkElevenLabs("el-bad", fail);
  expect(r.status).toBe("error");
});

test("checkBlotato skips when no key", async () => {
  expect((await checkBlotato(undefined, ok)).status).toBe("skipped");
});

test("checkVault ok when claims dir exists", async () => {
  const r = await checkVault(new URL("./fixtures/vault", import.meta.url).pathname);
  expect(r.status).toBe("ok");
  expect(r.detail).toMatch(/claim/i);
});

Run: cd oannes-engine && mkdir -p tests/fixtures/vault/Claims && printf -- '---\ntype: claim\nid: "clm-test01"\nclaimant: "[[Test Person]]"\nsource: "[[Test Episode]]"\nstatus: unverified\ntopics:\n - test-topic\ndate_display: "2001"\n---\n\n# Claim: a test claim happened\n\n## The Claim\nA test claim happened according to someone.\n' > tests/fixtures/vault/Claims/"Claim - a test claim happened.md" Expected: file created.

Run: cd oannes-engine && npx vitest run tests/preflight.test.ts Expected: FAIL — cannot find module ../src/preflight.js.

// src/preflight.ts
import * as fs from "node:fs/promises";
import * as path from "node:path";

export type CheckStatus = "ok" | "error" | "skipped";
export interface CheckResult { name: string; status: CheckStatus; detail: string; }

type Fetch = typeof fetch;

export async function checkElevenLabs(key: string | undefined, f: Fetch = fetch): Promise<CheckResult> {
  if (!key) return { name: "ElevenLabs", status: "skipped", detail: "no key set" };
  try {
    const res = await f("https://api.elevenlabs.io/v1/voices", { headers: { "xi-api-key": key } });
    return res.ok
      ? { name: "ElevenLabs", status: "ok", detail: `HTTP ${res.status}` }
      : { name: "ElevenLabs", status: "error", detail: `HTTP ${res.status}` };
  } catch (e) {
    return { name: "ElevenLabs", status: "error", detail: String(e) };
  }
}

export async function checkBlotato(key: string | undefined, f: Fetch = fetch): Promise<CheckResult> {
  if (!key) return { name: "Blotato", status: "skipped", detail: "no key set" };
  try {
    const res = await f("https://backend.blotato.com/v2/users/me", { headers: { "blotato-api-key": key } });
    return res.ok
      ? { name: "Blotato", status: "ok", detail: `HTTP ${res.status}` }
      : { name: "Blotato", status: "error", detail: `HTTP ${res.status}` };
  } catch (e) {
    return { name: "Blotato", status: "error", detail: String(e) };
  }
}

export async function checkVault(vaultDir: string): Promise<CheckResult> {
  try {
    const claimsDir = path.join(vaultDir, "Claims");
    const files = (await fs.readdir(claimsDir)).filter((x) => x.endsWith(".md"));
    return { name: "Vault", status: "ok", detail: `${files.length} claim files in ${claimsDir}` };
  } catch (e) {
    return { name: "Vault", status: "error", detail: String(e) };
  }
}

export async function runPreflight(cfg: {
  vaultDir: string;
  keys: { elevenlabs?: string; blotato?: string };
}): Promise<CheckResult[]> {
  return [
    await checkVault(cfg.vaultDir),
    await checkElevenLabs(cfg.keys.elevenlabs),
    await checkBlotato(cfg.keys.blotato),
  ];
}

Verified against Blotato docs (2026-06): base https://backend.blotato.com/v2, header blotato-api-key (send the key verbatim incl. any trailing = base64 padding — do not trim/encode). Key-validation endpoint is GET /v2/users/me. Connected socials: GET /v2/users/me/accounts. Publish: POST /v2/posts. Media-from-URL: POST /v2/media; presigned upload for local files: POST /v2/media/uploads.

// src/cli.ts
import "dotenv/config";
import { loadConfig } from "./config.js";
import { runPreflight } from "./preflight.js";

async function main() {
  const cmd = process.argv[2] ?? "preflight";
  const cfg = loadConfig();
  if (cmd === "preflight") {
    const results = await runPreflight(cfg);
    for (const r of results) {
      const icon = r.status === "ok" ? "✅" : r.status === "skipped" ? "⏭️ " : "❌";
      console.log(`${icon} ${r.name}: ${r.detail}`);
    }
    const failed = results.some((r) => r.status === "error");
    process.exit(failed ? 1 : 0);
  }
  console.error(`Unknown command: ${cmd}`);
  process.exit(2);
}

main();

Run: cd oannes-engine && npx vitest run tests/preflight.test.ts Expected: PASS (5 tests).

Run: cd oannes-engine && npm run engine preflight Expected: ✅ Vault: N claim files ...; services show ⏭️ skipped (no key) or ✅ ok (key present). Exit 0.

cd oannes-engine && git add src/preflight.ts src/cli.ts tests/preflight.test.ts tests/fixtures
git commit -m "feat(oannes-engine): preflight service reachability checks + CLI"

PHASE 1 — Stills MVP (claim → quote-card/carousel → approve → publish[dry-run])

Task 4: Read claims from the vault into typed objects

Files: - Create: oannes-engine/src/types.ts - Create: oannes-engine/src/graph/claims.ts - Test: oannes-engine/tests/claims.test.ts

// tests/claims.test.ts
import { loadClaims } from "../src/graph/claims.js";

const vault = new URL("./fixtures/vault", import.meta.url).pathname;

test("loadClaims parses frontmatter + claim text", async () => {
  const claims = await loadClaims(vault);
  expect(claims.length).toBeGreaterThanOrEqual(1);
  const c = claims.find((x) => x.id === "clm-test01")!;
  expect(c.claimant).toBe("Test Person");        // wikilink brackets stripped
  expect(c.source).toBe("Test Episode");
  expect(c.topics).toContain("test-topic");
  expect(c.claimText).toMatch(/test claim happened/i);
});

Run: cd oannes-engine && npx vitest run tests/claims.test.ts Expected: FAIL — cannot find module ../src/graph/claims.js.

// src/types.ts
export interface Claim {
  id: string;
  claimText: string;
  claimant: string;   // person name, brackets stripped ("" if none)
  source: string;     // episode name, brackets stripped ("" if none)
  topics: string[];
  status: string;
  dateDisplay: string;
  file: string;
}

export interface ContentBrief {
  claimId: string;
  format: "quote" | "carousel";
  claim: Claim;
}

export interface ScriptOutput {
  format: "quote" | "carousel";
  quote?: string;            // quote-card body
  attributionName?: string;  // e.g. "Bob Lazar"
  attributionRole?: string;  // e.g. "Physicist"
  slides?: { kicker: string; headline: string; sub: string }[]; // carousel
  caption: string;           // platform caption
  hashtags: string[];
}
// src/graph/claims.ts
import * as fs from "node:fs/promises";
import * as path from "node:path";
import matter from "gray-matter";
import type { Claim } from "../types.js";

function unwiki(v: unknown): string {
  if (typeof v !== "string") return "";
  return v.replace(/^\[\[/, "").replace(/\]\]$/, "").trim();
}

/** Pull the text under the "## The Claim" heading; fall back to the H1. */
function extractClaimText(body: string): string {
  const m = body.match(/##\s*The Claim\s*\n+([^\n#]+)/i);
  if (m) return m[1].trim();
  const h1 = body.match(/^#\s*Claim:\s*(.+)$/im);
  return h1 ? h1[1].trim() : body.trim().slice(0, 200);
}

export async function loadClaims(vaultDir: string): Promise<Claim[]> {
  const dir = path.join(vaultDir, "Claims");
  const files = (await fs.readdir(dir)).filter((f) => f.endsWith(".md"));
  const claims: Claim[] = [];
  for (const f of files) {
    const raw = await fs.readFile(path.join(dir, f), "utf8");
    const { data, content } = matter(raw);
    if (!data?.id) continue;
    claims.push({
      id: String(data.id),
      claimText: extractClaimText(content),
      claimant: unwiki(data.claimant),
      source: unwiki(data.source),
      topics: Array.isArray(data.topics) ? data.topics.map(String) : [],
      status: String(data.status ?? ""),
      dateDisplay: String(data.date_display ?? ""),
      file: f,
    });
  }
  return claims;
}

Run: cd oannes-engine && npx vitest run tests/claims.test.ts Expected: PASS.

cd oannes-engine && git add src/types.ts src/graph/claims.ts tests/claims.test.ts
git commit -m "feat(oannes-engine): vault claim reader"

Task 5: Curator — pick a fresh, high-quality claim → ContentBrief

Files: - Create: oannes-engine/src/curate/history.ts - Create: oannes-engine/src/curate/curator.ts - Test: oannes-engine/tests/curator.test.ts

Phase-1 heuristic (deterministic, simple): only consider claims that have a claimant (attribution is mandatory) and at least one topic; exclude ids already in history; among the rest pick the one with the most topics (proxy for “rich”), tie-broken by id for determinism. History is a JSON file of used ids.

// tests/curator.test.ts
import { chooseBrief } from "../src/curate/curator.js";
import type { Claim } from "../src/types.js";

const mk = (id: string, claimant: string, topics: string[]): Claim => ({
  id, claimText: `text ${id}`, claimant, source: "Ep", topics, status: "unverified", dateDisplay: "", file: `${id}.md`,
});

test("chooseBrief skips unattributed and used claims, prefers richest", () => {
  const claims = [
    mk("a", "", ["t1"]),                 // no claimant -> skip
    mk("b", "Person B", []),             // no topics -> skip
    mk("c", "Person C", ["t1"]),         // candidate
    mk("d", "Person D", ["t1", "t2"]),   // richest candidate
  ];
  const brief = chooseBrief(claims, new Set(["d"]), "quote"); // d used -> skip
  expect(brief?.claimId).toBe("c");
  expect(brief?.format).toBe("quote");
});

test("chooseBrief returns null when nothing eligible", () => {
  expect(chooseBrief([mk("a", "", [])], new Set(), "quote")).toBeNull();
});

Run: cd oannes-engine && npx vitest run tests/curator.test.ts Expected: FAIL — cannot find module.

// src/curate/history.ts
import * as fs from "node:fs/promises";

export async function readHistory(file: string): Promise<Set<string>> {
  try {
    const ids = JSON.parse(await fs.readFile(file, "utf8")) as string[];
    return new Set(ids);
  } catch {
    return new Set();
  }
}

export async function appendHistory(file: string, id: string): Promise<void> {
  const set = await readHistory(file);
  set.add(id);
  await fs.writeFile(file, JSON.stringify([...set], null, 2));
}
// src/curate/curator.ts
import type { Claim, ContentBrief } from "../types.js";

export function chooseBrief(
  claims: Claim[],
  used: Set<string>,
  format: "quote" | "carousel",
): ContentBrief | null {
  const eligible = claims
    .filter((c) => c.claimant.length > 0 && c.topics.length > 0 && !used.has(c.id))
    .sort((a, b) => b.topics.length - a.topics.length || a.id.localeCompare(b.id));
  const pick = eligible[0];
  return pick ? { claimId: pick.id, format, claim: pick } : null;
}

Run: cd oannes-engine && npx vitest run tests/curator.test.ts Expected: PASS.

cd oannes-engine && git add src/curate tests/curator.test.ts
git commit -m "feat(oannes-engine): curator + claim-use history"

Task 6: Scriptwriter — Claude turns a brief into attributed copy

Files: - Create: oannes-engine/src/script/scriptwriter.ts - Test: oannes-engine/tests/scriptwriter.test.ts

The Claude call is injected (runClaude) so tests don’t shell out. The real runner shells claude --print. Output is validated with Zod; attribution name is required (enforces the IP/voice rule from the spec).

// tests/scriptwriter.test.ts
import { writeScript, buildPrompt } from "../src/script/scriptwriter.js";
import type { ContentBrief } from "../src/types.js";

const brief: ContentBrief = {
  claimId: "clm-1", format: "quote",
  claim: { id: "clm-1", claimText: "DMT tunes you to a realm that is always there.",
    claimant: "Andrew Gallimore", source: "Ep", topics: ["dmt"], status: "unverified", dateDisplay: "", file: "x.md" },
};

test("buildPrompt includes the claim, claimant, and the attribution rule", () => {
  const p = buildPrompt(brief);
  expect(p).toContain("Andrew Gallimore");
  expect(p).toContain("DMT tunes you");
  expect(p.toLowerCase()).toContain("attribut");
});

test("writeScript parses fenced JSON and requires attribution", async () => {
  const fakeClaude = async () =>
    "Here you go:\n```json\n" +
    JSON.stringify({ format: "quote", quote: "DMT tunes you to a realm that's always there.",
      attributionName: "Andrew Gallimore", attributionRole: "Neuroscientist",
      caption: "What if DMT is a tuner, not a trip? 👀", hashtags: ["dmt", "consciousness"] }) +
    "\n```";
  const out = await writeScript(brief, fakeClaude);
  expect(out.attributionName).toBe("Andrew Gallimore");
  expect(out.hashtags).toContain("dmt");
});

test("writeScript throws when attribution missing", async () => {
  const bad = async () => '```json\n{"format":"quote","quote":"x","caption":"y","hashtags":[]}\n```';
  await expect(writeScript(brief, bad)).rejects.toThrow(/attribution/i);
});

Run: cd oannes-engine && npx vitest run tests/scriptwriter.test.ts Expected: FAIL — cannot find module.

// src/script/scriptwriter.ts
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { z } from "zod";
import type { ContentBrief, ScriptOutput } from "../types.js";

const execFileP = promisify(execFile);
export type RunClaude = (prompt: string) => Promise<string>;

const ScriptSchema = z.object({
  format: z.enum(["quote", "carousel"]),
  quote: z.string().optional(),
  attributionName: z.string().min(1, "attribution name required"),
  attributionRole: z.string().optional(),
  slides: z.array(z.object({ kicker: z.string(), headline: z.string(), sub: z.string() })).optional(),
  caption: z.string().min(1),
  hashtags: z.array(z.string()),
});

export function buildPrompt(brief: ContentBrief): string {
  const c = brief.claim;
  return [
    "You write punchy short-form social copy for a UFO/mysteries channel called Oannes.",
    "VOICE: provocative and intriguing, but EVERY claim must be ATTRIBUTED to the person who made it.",
    "Never assert a contested claim as objective fact; never fabricate; do not copy any source's wording — paraphrase.",
    "",
    `CLAIM: ${c.claimText}`,
    `CLAIMANT (attribute to this person): ${c.claimant}`,
    `TOPICS: ${c.topics.join(", ")}`,
    "",
    brief.format === "quote"
      ? 'Produce a QUOTE CARD. Return ONLY JSON: {"format":"quote","quote":"<=18 words, paraphrased","attributionName":"<person>","attributionRole":"<short role>","caption":"<hook caption with 1 emoji>","hashtags":["...4-6..."]}'
      : 'Produce a 5-SLIDE CAROUSEL. Return ONLY JSON: {"format":"carousel","attributionName":"<lead person>","slides":[{"kicker":"","headline":"","sub":""}],"caption":"<hook>","hashtags":["...4-6..."]}',
    "Output the JSON inside a ```json fenced block.",
  ].join("\n");
}

function parseFencedJson(text: string): unknown {
  const m = text.match(/```json\s*([\s\S]*?)```/i) ?? text.match(/```\s*([\s\S]*?)```/);
  const jsonStr = m ? m[1] : text;
  return JSON.parse(jsonStr.trim());
}

export const runClaudeCli: RunClaude = async (prompt) => {
  const { stdout } = await execFileP("claude", ["--print", prompt], { maxBuffer: 10 * 1024 * 1024 });
  return stdout;
};

export async function writeScript(brief: ContentBrief, run: RunClaude = runClaudeCli): Promise<ScriptOutput> {
  const raw = await run(buildPrompt(brief));
  const parsed = ScriptSchema.parse(parseFencedJson(raw));
  return parsed as ScriptOutput;
}

Run: cd oannes-engine && npx vitest run tests/scriptwriter.test.ts Expected: PASS (3 tests).

cd oannes-engine && git add src/script tests/scriptwriter.test.ts
git commit -m "feat(oannes-engine): Claude scriptwriter with enforced attribution"

Task 7: Remotion quote-card composition + still render

Files: - Create: oannes-engine/src/render/QuoteCard.tsx - Create: oannes-engine/src/render/Root.tsx - Create: oannes-engine/src/render/render.ts - Test: oannes-engine/tests/render.test.ts

Remotion still rendering is integration-heavy; we TDD the input-prep (quoteCardProps) and a smoke render that asserts a PNG file is produced.

// tests/render.test.ts
import { quoteCardProps } from "../src/render/render.js";
import type { ScriptOutput } from "../src/types.js";

test("quoteCardProps maps script to composition inputProps", () => {
  const s: ScriptOutput = { format: "quote", quote: "DMT tunes you.",
    attributionName: "Andrew Gallimore", attributionRole: "Neuroscientist", caption: "c", hashtags: [] };
  const props = quoteCardProps(s);
  expect(props.quote).toBe("DMT tunes you.");
  expect(props.name).toBe("Andrew Gallimore");
  expect(props.role).toBe("Neuroscientist");
});

Run: cd oannes-engine && npx vitest run tests/render.test.ts Expected: FAIL — cannot find module.

// src/render/QuoteCard.tsx
import React from "react";
import { AbsoluteFill } from "remotion";

export interface QuoteCardProps { quote: string; name: string; role: string; }

export const QuoteCard: React.FC<QuoteCardProps> = ({ quote, name, role }) => (
  <AbsoluteFill style={{ background: "linear-gradient(155deg,#0a0a14 0%,#13243a 55%,#1b1340 100%)",
    fontFamily: "Outfit, sans-serif", padding: 120, color: "#fff", justifyContent: "space-between" }}>
    <div style={{ fontFamily: "Archivo Black, sans-serif", fontSize: 64, color: "#56dbd0" }}>OANNES</div>
    <div>
      <div style={{ fontSize: 240, lineHeight: 0.6, color: "rgba(86,219,208,.18)" }}>&ldquo;</div>
      <div style={{ fontFamily: "Archivo Black, sans-serif", fontSize: 96, lineHeight: 1.12 }}>{quote}</div>
    </div>
    <div>
      <div style={{ fontSize: 52, fontWeight: 800, color: "#56dbd0" }}>{name}</div>
      <div style={{ fontSize: 38, color: "#9a9ab0" }}>{role}</div>
    </div>
  </AbsoluteFill>
);
// src/render/Root.tsx
import React from "react";
import { Composition, Still } from "remotion";
import { QuoteCard } from "./QuoteCard.js";

export const RemotionRoot: React.FC = () => (
  <>
    <Still id="QuoteCard" component={QuoteCard} width={1080} height={1920}
      defaultProps={{ quote: "", name: "", role: "" }} />
  </>
);
// src/render/render.ts
import * as path from "node:path";
import { bundle } from "@remotion/bundler";
import { renderStill, selectComposition } from "@remotion/renderer";
import type { ScriptOutput } from "../types.js";
import type { QuoteCardProps } from "./QuoteCard.js";

export function quoteCardProps(s: ScriptOutput): QuoteCardProps {
  return { quote: s.quote ?? "", name: s.attributionName ?? "", role: s.attributionRole ?? "" };
}

let bundlePromise: Promise<string> | null = null;
function getBundle(): Promise<string> {
  if (!bundlePromise) {
    bundlePromise = bundle({ entryPoint: path.resolve("src/render/index.ts") });
  }
  return bundlePromise;
}

export async function renderQuoteCard(s: ScriptOutput, outPath: string): Promise<string> {
  const serveUrl = await getBundle();
  const composition = await selectComposition({ serveUrl, id: "QuoteCard", inputProps: quoteCardProps(s) });
  await renderStill({ composition, serveUrl, output: outPath, inputProps: quoteCardProps(s) });
  return outPath;
}
// src/render/index.ts
import { registerRoot } from "remotion";
import { RemotionRoot } from "./Root.js";
registerRoot(RemotionRoot);

Run: cd oannes-engine && npx vitest run tests/render.test.ts Expected: PASS.

Run:

cd oannes-engine && mkdir -p renders && npx tsx -e "import {renderQuoteCard} from './src/render/render.js'; await renderQuoteCard({format:'quote',quote:'DMT tunes you to a realm that is always there.',attributionName:'Andrew Gallimore',attributionRole:'Neuroscientist',caption:'',hashtags:[]}, './renders/smoke.png'); console.log('rendered');"

Expected: prints rendered; renders/smoke.png exists (1080×1920). Open it to eyeball the branding.

cd oannes-engine && git add src/render tests/render.test.ts
git commit -m "feat(oannes-engine): Remotion quote-card still render"

Files: - Create: oannes-engine/src/render/Carousel.tsx - Modify: oannes-engine/src/render/Root.tsx (register the Carousel still) - Modify: oannes-engine/src/render/render.ts (add renderCarousel) - Test: oannes-engine/tests/carousel.test.ts

// tests/carousel.test.ts
import { carouselSlideProps } from "../src/render/render.js";
import type { ScriptOutput } from "../src/types.js";

test("carouselSlideProps yields one prop set per slide with index labels", () => {
  const s: ScriptOutput = { format: "carousel", attributionName: "X",
    slides: [{ kicker: "k1", headline: "h1", sub: "s1" }, { kicker: "k2", headline: "h2", sub: "s2" }],
    caption: "c", hashtags: [] };
  const props = carouselSlideProps(s);
  expect(props).toHaveLength(2);
  expect(props[0].index).toBe("1/2");
  expect(props[1].headline).toBe("h2");
});

Run: cd oannes-engine && npx vitest run tests/carousel.test.ts Expected: FAIL — carouselSlideProps not exported.

// src/render/Carousel.tsx
import React from "react";
import { AbsoluteFill } from "remotion";

export interface SlideProps { kicker: string; headline: string; sub: string; index: string; }

export const CarouselSlide: React.FC<SlideProps> = ({ kicker, headline, sub, index }) => (
  <AbsoluteFill style={{ background: "linear-gradient(160deg,#0d1b2a,#1b1340)",
    fontFamily: "Outfit, sans-serif", padding: 110, color: "#fff", justifyContent: "flex-end" }}>
    <div style={{ position: "absolute", top: 90, right: 110, fontSize: 40, color: "#6a6a80", fontWeight: 700 }}>{index}</div>
    <div style={{ position: "absolute", top: 110, left: 110, fontSize: 36, letterSpacing: 6,
      textTransform: "uppercase", color: "#56dbd0", fontWeight: 700 }}>{kicker}</div>
    <div style={{ fontFamily: "Archivo Black, sans-serif", fontSize: 92, lineHeight: 1.1, textTransform: "uppercase" }}>{headline}</div>
    <div style={{ fontSize: 44, color: "#b8b8cc", marginTop: 40, lineHeight: 1.4 }}>{sub}</div>
    <div style={{ position: "absolute", bottom: 70, left: 110, fontFamily: "Archivo Black, sans-serif", fontSize: 40, color: "#56dbd0" }}>OANNES</div>
  </AbsoluteFill>
);
    <Still id="CarouselSlide" component={CarouselSlide} width={1080} height={1350}
      defaultProps={{ kicker: "", headline: "", sub: "", index: "1/1" }} />

And add the import at the top of Root.tsx:

import { CarouselSlide } from "./Carousel.js";
import type { SlideProps } from "./Carousel.js";

export function carouselSlideProps(s: ScriptOutput): SlideProps[] {
  const slides = s.slides ?? [];
  return slides.map((sl, i) => ({ ...sl, index: `${i + 1}/${slides.length}` }));
}

export async function renderCarousel(s: ScriptOutput, outDir: string): Promise<string[]> {
  const { renderStill, selectComposition } = await import("@remotion/renderer");
  const serveUrl = await getBundle();
  const out: string[] = [];
  const slides = carouselSlideProps(s);
  for (let i = 0; i < slides.length; i++) {
    const inputProps = slides[i] as unknown as Record<string, unknown>;
    const composition = await selectComposition({ serveUrl, id: "CarouselSlide", inputProps });
    const file = `${outDir}/slide-${i + 1}.png`;
    await renderStill({ composition, serveUrl, output: file, inputProps });
    out.push(file);
  }
  return out;
}

Run: cd oannes-engine && npx vitest run tests/carousel.test.ts Expected: PASS.

cd oannes-engine && git add src/render tests/carousel.test.ts
git commit -m "feat(oannes-engine): Remotion carousel slide render"

Task 9: Approval queue — write previews, approve/reject from a folder + Discord

Files: - Create: oannes-engine/src/queue/approval.ts - Create: oannes-engine/src/queue/discord.ts - Modify: oannes-engine/src/cli.ts (add list, approve <id>, reject <id> commands) - Test: oannes-engine/tests/approval.test.ts

A “pending item” is a folder renders/pending/<claimId>/ holding the image(s) + meta.json (caption, hashtags, format). Approve moves it to renders/approved/<claimId>/; reject moves to renders/rejected/<claimId>/. Discord preview is best-effort (skipped if no webhook).

// tests/approval.test.ts
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { enqueue, listPending, approve, reject } from "../src/queue/approval.js";

async function tmp() { return await fs.mkdtemp(path.join(os.tmpdir(), "oannes-")); }

test("enqueue then list then approve moves the item", async () => {
  const root = await tmp();
  await enqueue(root, { claimId: "clm-x", format: "quote", caption: "hi", hashtags: ["a"], files: [] });
  expect(await listPending(root)).toContain("clm-x");
  await approve(root, "clm-x");
  expect(await listPending(root)).not.toContain("clm-x");
  expect(await fs.readdir(path.join(root, "approved"))).toContain("clm-x");
});

test("reject moves to rejected", async () => {
  const root = await tmp();
  await enqueue(root, { claimId: "clm-y", format: "quote", caption: "h", hashtags: [], files: [] });
  await reject(root, "clm-y");
  expect(await fs.readdir(path.join(root, "rejected"))).toContain("clm-y");
});

Run: cd oannes-engine && npx vitest run tests/approval.test.ts Expected: FAIL — cannot find module.

// src/queue/approval.ts
import * as fs from "node:fs/promises";
import * as path from "node:path";

export interface QueueItem {
  claimId: string; format: "quote" | "carousel"; caption: string; hashtags: string[]; files: string[];
}

const sub = (root: string, s: string) => path.join(root, s);

export async function enqueue(root: string, item: QueueItem): Promise<string> {
  const dir = sub(root, path.join("pending", item.claimId));
  await fs.mkdir(dir, { recursive: true });
  for (const f of item.files) await fs.copyFile(f, path.join(dir, path.basename(f)));
  await fs.writeFile(path.join(dir, "meta.json"), JSON.stringify(item, null, 2));
  return dir;
}

export async function listPending(root: string): Promise<string[]> {
  try { return await fs.readdir(sub(root, "pending")); } catch { return []; }
}

async function move(root: string, id: string, to: "approved" | "rejected"): Promise<void> {
  const from = sub(root, path.join("pending", id));
  const dest = sub(root, path.join(to, id));
  await fs.mkdir(path.dirname(dest), { recursive: true });
  await fs.rename(from, dest);
}

export const approve = (root: string, id: string) => move(root, id, "approved");
export const reject = (root: string, id: string) => move(root, id, "rejected");

export async function readItem(root: string, base: "approved" | "pending", id: string): Promise<QueueItem> {
  const raw = await fs.readFile(sub(root, path.join(base, id, "meta.json")), "utf8");
  return JSON.parse(raw) as QueueItem;
}
// src/queue/discord.ts
export async function sendDiscordPreview(
  webhook: string | undefined,
  content: string,
  imagePaths: string[],
  f: typeof fetch = fetch,
): Promise<void> {
  if (!webhook) return; // best-effort; skipped without a webhook
  const fs = await import("node:fs/promises");
  const form = new FormData();
  form.append("content", content.slice(0, 1900));
  for (let i = 0; i < imagePaths.length; i++) {
    const buf = await fs.readFile(imagePaths[i]);
    form.append(`files[${i}]`, new Blob([buf], { type: "image/png" }), `preview-${i}.png`);
  }
  await f(webhook, { method: "POST", body: form });
}
  if (cmd === "list") {
    const { listPending } = await import("./queue/approval.js");
    const ids = await listPending(`${cfg.renderOutputDir}`);
    console.log(ids.length ? ids.join("\n") : "(nothing pending)");
    return;
  }
  if (cmd === "approve" || cmd === "reject") {
    const id = process.argv[3];
    if (!id) { console.error(`usage: ${cmd} <claimId>`); process.exit(2); }
    const { approve, reject } = await import("./queue/approval.js");
    await (cmd === "approve" ? approve : reject)(`${cfg.renderOutputDir}`, id);
    console.log(`${cmd}d ${id}`);
    return;
  }

Run: cd oannes-engine && npx vitest run tests/approval.test.ts Expected: PASS (2 tests).

cd oannes-engine && git add src/queue src/cli.ts tests/approval.test.ts
git commit -m "feat(oannes-engine): folder-based approval queue + Discord preview + CLI"

Task 10: Publisher — Blotato client, DRY_RUN aware

Files: - Create: oannes-engine/src/publish/blotato.ts - Test: oannes-engine/tests/blotato.test.ts

Phase 1 publishes to TikTok only. In DRY_RUN, it returns a simulated result and makes no network call.

// tests/blotato.test.ts
import { publishImagePost } from "../src/publish/blotato.js";

test("DRY_RUN returns simulated result, no fetch", async () => {
  let called = false;
  const f = (async () => { called = true; return {} as Response; }) as typeof fetch;
  const r = await publishImagePost(
    { apiKey: "k", dryRun: true, platform: "tiktok", accountId: "acc1" },
    { caption: "hi", hashtags: ["a"], imageUrls: ["http://x/1.png"] }, f);
  expect(r.dryRun).toBe(true);
  expect(called).toBe(false);
});

test("live mode posts and returns id", async () => {
  const f = (async () => ({ ok: true, status: 200,
    json: async () => ({ id: "post_123" }) }) as unknown as Response) as typeof fetch;
  const r = await publishImagePost(
    { apiKey: "k", dryRun: false, platform: "tiktok", accountId: "acc1" },
    { caption: "hi", hashtags: ["a"], imageUrls: ["http://x/1.png"] }, f);
  expect(r.dryRun).toBe(false);
  expect(r.postId).toBe("post_123");
});

Run: cd oannes-engine && npx vitest run tests/blotato.test.ts Expected: FAIL — cannot find module.

// src/publish/blotato.ts
export interface PublishConfig {
  apiKey: string; dryRun: boolean; platform: "tiktok" | "instagram" | "youtube" | "facebook"; accountId: string;
}
export interface ImagePost { caption: string; hashtags: string[]; imageUrls: string[]; }
export interface PublishResult { dryRun: boolean; postId?: string; }

export async function publishImagePost(
  cfg: PublishConfig, post: ImagePost, f: typeof fetch = fetch,
): Promise<PublishResult> {
  const text = `${post.caption}\n\n${post.hashtags.map((h) => `#${h}`).join(" ")}`.trim();
  if (cfg.dryRun) {
    console.log(`[DRY_RUN] would post to ${cfg.platform}:\n${text}\nimages: ${post.imageUrls.join(", ")}`);
    return { dryRun: true };
  }
  // NOTE: verify Blotato's current /v2/posts schema at execution; shape below matches their documented payload.
  const res = await f("https://backend.blotato.com/v2/posts", {
    method: "POST",
    headers: { "blotato-api-key": cfg.apiKey, "content-type": "application/json" },
    body: JSON.stringify({
      post: { accountId: cfg.accountId, target: { targetType: cfg.platform },
        content: { text, mediaUrls: post.imageUrls } },
    }),
  });
  if (!res.ok) throw new Error(`Blotato post failed: HTTP ${res.status}`);
  const body = (await res.json()) as { id?: string };
  return { dryRun: false, postId: body.id };
}

The Blotato request shape and the image-upload step (Blotato typically requires uploading media to get hosted URLs first via /v2/media) must be confirmed against current Blotato docs at execution. For Phase 1 we run DRY_RUN=true, so the live path is exercised only after a human verifies the schema. Keep all Blotato specifics inside this file.

Run: cd oannes-engine && npx vitest run tests/blotato.test.ts Expected: PASS (2 tests).

cd oannes-engine && git add src/publish tests/blotato.test.ts
git commit -m "feat(oannes-engine): Blotato publisher (DRY_RUN aware, TikTok)"

Task 11: Orchestrator — run-once ties the slice together

Files: - Create: oannes-engine/src/orchestrator.ts - Modify: oannes-engine/src/cli.ts (add run-once command) - Test: oannes-engine/tests/orchestrator.test.ts

runOnce is fully dependency-injected (load claims, run Claude, render, enqueue) so it tests with zero network/render. The CLI wires the real implementations.

// tests/orchestrator.test.ts
import * as fs from "node:fs/promises";
import * as os from "node:os";
import * as path from "node:path";
import { runOnce } from "../src/orchestrator.js";
import type { Claim, ScriptOutput } from "../src/types.js";

const claim: Claim = { id: "clm-1", claimText: "t", claimant: "P", source: "Ep",
  topics: ["a", "b"], status: "unverified", dateDisplay: "", file: "f.md" };
const script: ScriptOutput = { format: "quote", quote: "q", attributionName: "P",
  attributionRole: "Role", caption: "cap", hashtags: ["a"] };

test("runOnce curates, scripts, renders, enqueues, records history", async () => {
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "oannes-run-"));
  const used = new Set<string>();
  const result = await runOnce({
    renderRoot: root, format: "quote",
    loadClaims: async () => [claim],
    readUsed: async () => used,
    writeUsed: async (id) => { used.add(id); },
    writeScript: async () => script,
    renderQuote: async (_s, outPath) => { await fs.writeFile(outPath, "PNG"); return outPath; },
  });
  expect(result.claimId).toBe("clm-1");
  expect(used.has("clm-1")).toBe(true);
  expect(await fs.readdir(path.join(root, "pending"))).toContain("clm-1");
});

test("runOnce returns null when nothing eligible", async () => {
  const root = await fs.mkdtemp(path.join(os.tmpdir(), "oannes-run-"));
  const r = await runOnce({
    renderRoot: root, format: "quote",
    loadClaims: async () => [],
    readUsed: async () => new Set(),
    writeUsed: async () => {},
    writeScript: async () => script,
    renderQuote: async (_s, p) => p,
  });
  expect(r).toBeNull();
});

Run: cd oannes-engine && npx vitest run tests/orchestrator.test.ts Expected: FAIL — cannot find module.

// src/orchestrator.ts
import * as path from "node:path";
import { chooseBrief } from "./curate/curator.js";
import { enqueue } from "./queue/approval.js";
import type { Claim, ScriptOutput } from "./types.js";

export interface RunOnceDeps {
  renderRoot: string;
  format: "quote" | "carousel";
  loadClaims: () => Promise<Claim[]>;
  readUsed: () => Promise<Set<string>>;
  writeUsed: (id: string) => Promise<void>;
  writeScript: (brief: ReturnType<typeof chooseBrief>) => Promise<ScriptOutput>;
  renderQuote: (s: ScriptOutput, outPath: string) => Promise<string>;
}

export interface RunOnceResult { claimId: string; pendingDir: string; }

export async function runOnce(deps: RunOnceDeps): Promise<RunOnceResult | null> {
  const claims = await deps.loadClaims();
  const used = await deps.readUsed();
  const brief = chooseBrief(claims, used, deps.format);
  if (!brief) return null;

  const script = await deps.writeScript(brief);

  const stage = path.join(deps.renderRoot, "_stage", brief.claimId);
  const fs = await import("node:fs/promises");
  await fs.mkdir(stage, { recursive: true });
  const img = path.join(stage, "quote.png");
  await deps.renderQuote(script, img);

  const pendingDir = await enqueue(deps.renderRoot, {
    claimId: brief.claimId, format: "quote", caption: script.caption,
    hashtags: script.hashtags, files: [img],
  });

  await deps.writeUsed(brief.claimId);
  return { claimId: brief.claimId, pendingDir };
}
  if (cmd === "run-once") {
    const { loadClaims } = await import("./graph/claims.js");
    const { readHistory, appendHistory } = await import("./curate/history.js");
    const { writeScript } = await import("./script/scriptwriter.js");
    const { renderQuoteCard } = await import("./render/render.js");
    const historyFile = `${cfg.renderOutputDir}/used.json`;
    const result = await (await import("./orchestrator.js")).runOnce({
      renderRoot: cfg.renderOutputDir, format: "quote",
      loadClaims: () => loadClaims(cfg.vaultDir),
      readUsed: () => readHistory(historyFile),
      writeUsed: (id) => appendHistory(historyFile, id),
      writeScript: (brief) => writeScript(brief!),
      renderQuote: renderQuoteCard,
    });
    console.log(result ? `queued ${result.claimId} → ${result.pendingDir}` : "nothing eligible");
    return;
  }

Run: cd oannes-engine && npx vitest run tests/orchestrator.test.ts Expected: PASS (2 tests).

Run:

cd oannes-engine && npm run engine run-once && npm run engine list

Expected: queued clm-... → renders/pending/clm-...; list shows that id; renders/pending/<id>/quote.png exists and looks on-brand. (Requires claude CLI; no paid keys needed.)

Run: cd oannes-engine && npm test Expected: ALL tests pass.

cd oannes-engine && git add src/orchestrator.ts src/cli.ts tests/orchestrator.test.ts
git commit -m "feat(oannes-engine): run-once orchestrator (curate→script→render→queue)"

Definition of done (Phase 0 + 1)

What Phase 2+ adds (not in this plan)


Self-review notes (done by author)