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)
- Node 20+ installed (
node -v). claudeCLI on PATH (already used by the Oannes Python pipeline).oannes-engine/.envexists (committed.env.exampledocuments it). Keys may be added progressively — the preflight (Task 3) skips services whose keys are absent, so the plan is executable before all keys arrive.- Phase 1 needs no paid keys to build/test: rendering is local, publishing is
DRY_RUN.fal.aiandBlotatokeys are only needed to exit dry-run.
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
- [ ] Step 1: Create
package.json
{
"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"
}
}
- [ ] Step 2: Create
tsconfig.json
{
"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"]
}
- [ ] Step 3: Create
vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { globals: true, environment: "node" },
});
- [ ] Step 4: Install dependencies
Run: cd oannes-engine && npm install
Expected: completes, creates node_modules/ and package-lock.json.
- [ ] Step 5: Commit
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
- [ ] Step 1: Write the failing test
// 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
});
- [ ] Step 2: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/config.test.ts
Expected: FAIL — cannot find module ../src/config.js.
- [ ] Step 3: Write minimal implementation
// 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),
},
};
}
- [ ] Step 4: Run test to verify it passes
Run: cd oannes-engine && npx vitest run tests/config.test.ts
Expected: PASS (2 tests).
- [ ] Step 5: Commit
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" }.
- [ ] Step 1: Write the failing test
// 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);
});
- [ ] Step 2: Create the fixture vault dir so
checkVaulthas something to find
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.
- [ ] Step 3: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/preflight.test.ts
Expected: FAIL — cannot find module ../src/preflight.js.
- [ ] Step 4: Write minimal implementation
// 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, headerblotato-api-key(send the key verbatim incl. any trailing=base64 padding — do not trim/encode). Key-validation endpoint isGET /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.
- [ ] Step 5: Create the CLI entry that runs preflight
// 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();
- [ ] Step 6: Run test to verify it passes
Run: cd oannes-engine && npx vitest run tests/preflight.test.ts
Expected: PASS (5 tests).
- [ ] Step 7: Run the real preflight against your actual
.env
Run: cd oannes-engine && npm run engine preflight
Expected: ✅ Vault: N claim files ...; services show ⏭️ skipped (no key) or ✅ ok (key present). Exit 0.
- [ ] Step 8: Commit
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
- [ ] Step 1: Write the failing test (uses the fixture from Task 3)
// 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);
});
- [ ] Step 2: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/claims.test.ts
Expected: FAIL — cannot find module ../src/graph/claims.js.
- [ ] Step 3: Write minimal implementation
// 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;
}
- [ ] Step 4: Run test to verify it passes
Run: cd oannes-engine && npx vitest run tests/claims.test.ts
Expected: PASS.
- [ ] Step 5: Commit
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.
- [ ] Step 1: Write the failing test
// 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();
});
- [ ] Step 2: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/curator.test.ts
Expected: FAIL — cannot find module.
- [ ] Step 3: Write minimal implementation
// 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;
}
- [ ] Step 4: Run test to verify it passes
Run: cd oannes-engine && npx vitest run tests/curator.test.ts
Expected: PASS.
- [ ] Step 5: Commit
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).
- [ ] Step 1: Write the failing test
// 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);
});
- [ ] Step 2: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/scriptwriter.test.ts
Expected: FAIL — cannot find module.
- [ ] Step 3: Write minimal implementation
// 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;
}
- [ ] Step 4: Run test to verify it passes
Run: cd oannes-engine && npx vitest run tests/scriptwriter.test.ts
Expected: PASS (3 tests).
- [ ] Step 5: Commit
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.
- [ ] Step 1: Write the failing test
// 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");
});
- [ ] Step 2: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/render.test.ts
Expected: FAIL — cannot find module.
- [ ] Step 3: Create the composition (port the mockup’s quote-card styling)
// 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)" }}>“</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);
- [ ] Step 4: Run unit test to verify it passes
Run: cd oannes-engine && npx vitest run tests/render.test.ts
Expected: PASS.
- [ ] Step 5: Smoke-test a real render
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.
- [ ] Step 6: Commit
cd oannes-engine && git add src/render tests/render.test.ts
git commit -m "feat(oannes-engine): Remotion quote-card still render"
Task 8: Carousel composition + multi-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
- [ ] Step 1: Write the failing test
// 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");
});
- [ ] Step 2: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/carousel.test.ts
Expected: FAIL — carouselSlideProps not exported.
- [ ] Step 3: Create the slide composition
// 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>
);
- [ ] Step 4: Register it in
Root.tsx(add inside the fragment, after the QuoteCardStill)
<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";
- [ ] Step 5: Add render functions to
render.ts(append)
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;
}
- [ ] Step 6: Run test to verify it passes
Run: cd oannes-engine && npx vitest run tests/carousel.test.ts
Expected: PASS.
- [ ] Step 7: Commit
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).
- [ ] Step 1: Write the failing test
// 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");
});
- [ ] Step 2: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/approval.test.ts
Expected: FAIL — cannot find module.
- [ ] Step 3: Write minimal implementation
// 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 });
}
- [ ] Step 4: Add CLI commands — in
src/cli.ts, add these branches insidemain()before the “Unknown command” line:
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;
}
- [ ] Step 5: Run test to verify it passes
Run: cd oannes-engine && npx vitest run tests/approval.test.ts
Expected: PASS (2 tests).
- [ ] Step 6: Commit
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.
- [ ] Step 1: Write the failing test
// 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");
});
- [ ] Step 2: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/blotato.test.ts
Expected: FAIL — cannot find module.
- [ ] Step 3: Write minimal implementation
// 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 runDRY_RUN=true, so the live path is exercised only after a human verifies the schema. Keep all Blotato specifics inside this file.
- [ ] Step 4: Run test to verify it passes
Run: cd oannes-engine && npx vitest run tests/blotato.test.ts
Expected: PASS (2 tests).
- [ ] Step 5: Commit
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.
- [ ] Step 1: Write the failing test
// 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();
});
- [ ] Step 2: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/orchestrator.test.ts
Expected: FAIL — cannot find module.
- [ ] Step 3: Write minimal implementation
// 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 };
}
- [ ] Step 4: Wire
run-onceinto the CLI — add tosrc/cli.tsbefore “Unknown command”:
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;
}
- [ ] Step 5: Run test to verify it passes
Run: cd oannes-engine && npx vitest run tests/orchestrator.test.ts
Expected: PASS (2 tests).
- [ ] Step 6: Full end-to-end smoke (real Claude + real render, still DRY for publish)
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.)
- [ ] Step 7: Run the whole test suite
Run: cd oannes-engine && npm test
Expected: ALL tests pass.
- [ ] Step 8: Commit
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)
npm run engine preflightreports vault OK and each service ok/skipped.npm run engine run-onceproduces a branded quote-card PNG inrenders/pending/<claimId>/with caption + hashtags, attribution enforced, and records the claim inused.json.npm run engine list/approve <id>/reject <id>move items betweenpending/,approved/,rejected/.- The Blotato publisher exists and is exercised in
DRY_RUN; going live is a deliberate later step after verifying Blotato’s media/post schema and connecting@oannescodeaccounts in the Blotato dashboard. npm testgreen.
What Phase 2+ adds (not in this plan)
- ElevenLabs voiceover + Remotion video composition (reels/shorts).
- Wire
renderCarousel(Task 8) into the orchestrator as a second format. - Blotato media upload + go-live; fan-out to IG / YT / FB.
- Cambium Rhythms trigger for the daily cycle; performance learning loop into the graph.
Self-review notes (done by author)
- Spec coverage: Phase 0 (wiring/preflight) → Tasks 1–3. Phase 1 (curate→script→render→approve→publish[dry-run], stills) → Tasks 4–11. Attribution IP rule → enforced in Task 6 (Zod requires
attributionName) and Task 5 (curator skips unattributed claims). Approve-from-phone → Task 9 (folder queue + Discord webhook). TikTok-first single platform → Task 10. Deferred per spec non-goals: voice/video, fan-out, autonomy, learning loop (listed under Phase 2+). - Placeholder scan: no TBD/TODO in steps; the two “verify against current docs” notes (Blotato URL/schema) are explicit human-verification callouts gated behind
DRY_RUN, not missing code. - Type consistency:
Claim,ContentBrief,ScriptOutputdefined in Task 4 and used unchanged in Tasks 5/6/7/8/11.chooseBrief(claims, used, format),writeScript(brief, run?),renderQuoteCard(s, outPath),enqueue(root, item)signatures match across their definition and call sites.