🦄 Unicorn.Land ▸ docs/superpowers/plans/2026-06-27-oannes-per-post-variety.md
updated 2026-06-28

Oannes Per-Post Variety 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: Rotate narrator voice + narration structure per post (no two consecutive drops share either), wired into the learning loop as new learnable dimensions, to break the templated/reused-narration signature YouTube’s inauthentic-content policy flags.

Architecture: Mirror the existing artKey dimension exactly. Two new code-constant pools (voices.ts, structures.ts); planTodayLearned picks a fresh (non-recent) voice + structure via the existing epsilon-greedy money-weighted bias; both are stored on each post’s metrics snapshot and aggregated into weights.json so the engine can learn them. Voice + structure apply to reels only — carousels record "" so the learning signal stays reel-clean.

Tech Stack: TypeScript (ESM, .js import specifiers), vitest (npm test = tsc --noEmit && vitest run), Postgres (pg), ElevenLabs TTS, Claude CLI scriptwriter.

Spec: docs/superpowers/specs/2026-06-27-oannes-per-post-variety-design.md

Working dir for all commands: oannes-engine/

Key conventions: - Tests live in oannes-engine/tests/*.test.ts, import source as ../src/<path>.js. - Run a single test file: npx vitest run tests/<file>.test.ts. - Every commit must compile (tsc --noEmit gates the suite). Interface changes update every construction site + test literal in the same commit. - Commit from the repo root (/Users/joshua/Projects/Unicorn.Land).


Task 1: Voice pool module

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

Create oannes-engine/tests/voices.test.ts:

import { describe, it, expect } from "vitest";
import { VOICE_POOL, VOICE_KEYS, voiceIdForKey, voiceLabel } from "../src/media/voices.js";

describe("voices", () => {
  it("exposes the 4 on-brand narrator voices, Ash first", () => {
    expect(VOICE_KEYS).toEqual(["ash", "brian", "george", "bill"]);
    expect(VOICE_POOL[0].voiceId).toBe("VU16byTywsWv5JpI8rbc");
  });
  it("voiceIdForKey resolves a known key and falls back to Ash", () => {
    expect(voiceIdForKey("brian")).toBe("nPczCjzI2devNBz1zQrb");
    expect(voiceIdForKey("nope")).toBe("VU16byTywsWv5JpI8rbc");
  });
  it("voiceLabel returns a human label", () => {
    expect(voiceLabel("george")).toContain("George");
  });
});

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

Create oannes-engine/src/media/voices.ts:

/** On-brand male narrator voices for the Oannes reel VO. Voice applies to reels only. */
export interface VoiceOption {
  key: string;
  voiceId: string; // ElevenLabs voice id
  label: string;
}

export const VOICE_POOL: VoiceOption[] = [
  { key: "ash", voiceId: "VU16byTywsWv5JpI8rbc", label: "Ash — calm, magnetic" },
  { key: "brian", voiceId: "nPczCjzI2devNBz1zQrb", label: "Brian — deep, resonant" },
  { key: "george", voiceId: "JBFqnCBsd6RMkjVDRZzb", label: "George — storyteller" },
  { key: "bill", voiceId: "pqHfZKP75CvOlQylNhV4", label: "Bill — wise, gravitas" },
];

export const VOICE_KEYS: string[] = VOICE_POOL.map((v) => v.key);

/** Resolve a voice key to its ElevenLabs id, falling back to the first (Ash). */
export function voiceIdForKey(key: string): string {
  return (VOICE_POOL.find((v) => v.key === key) ?? VOICE_POOL[0]).voiceId;
}

export function voiceLabel(key: string): string {
  return (VOICE_POOL.find((v) => v.key === key) ?? VOICE_POOL[0]).label;
}

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

cd /Users/joshua/Projects/Unicorn.Land
git add oannes-engine/src/media/voices.ts oannes-engine/tests/voices.test.ts
git commit -m "feat(oannes-engine): on-brand narrator voice pool"

Task 2: Structure archetypes module

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

Create oannes-engine/tests/structures.test.ts:

import { describe, it, expect } from "vitest";
import { STRUCTURES, STRUCTURE_KEYS, structureDirective } from "../src/script/structures.js";

describe("structures", () => {
  it("exposes 6 distinct narration archetypes", () => {
    expect(STRUCTURE_KEYS).toEqual([
      "cold_open", "question_spiral", "witness", "contrarian", "escalation", "confession",
    ]);
    expect(new Set(STRUCTURE_KEYS).size).toBe(6);
  });
  it("each archetype has a non-trivial directive", () => {
    for (const s of STRUCTURES) expect(s.directive.length).toBeGreaterThan(20);
  });
  it("structureDirective resolves a key, undefined for unknown", () => {
    expect(structureDirective("witness")).toContain("second person");
    expect(structureDirective("nope")).toBeUndefined();
  });
});

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

Create oannes-engine/src/script/structures.ts:

/** Narration shape archetypes injected into the reel-script prompt (reels only). */
export interface StructureOption {
  key: string;
  directive: string;
}

export const STRUCTURES: StructureOption[] = [
  { key: "cold_open", directive: "COLD OPEN: Drop straight into the single strangest image or detail of the claim in the first sentence — no setup. Only after the viewer is hooked, reveal who said it and what it means." },
  { key: "question_spiral", directive: "QUESTION SPIRAL: Open on one provocative question, then escalate through two or three progressively deeper questions, and land on the attributed claim as the unsettling answer." },
  { key: "witness", directive: "WITNESS IMMERSION: Use second person ('Picture this...', 'Imagine you're...') to place the viewer inside the scene of the claim, then pull back and attribute it to the claimant." },
  { key: "contrarian", directive: "CONTRARIAN PIVOT: State the consensus or what everyone assumes first, then pivot hard to the claimant's challenge to it (the 'but [claimant] says the opposite' turn)." },
  { key: "escalation", directive: "ESCALATION: Start with the smallest, most plausible version of the claim, then ratchet through increasingly wild implications toward a final mind-bending payoff." },
  { key: "confession", directive: "CONFESSION / REVELATION: Frame the claim as a startling admission the claimant made — something most experts won't say out loud — and build toward the moment of revelation." },
];

export const STRUCTURE_KEYS: string[] = STRUCTURES.map((s) => s.key);

export function structureDirective(key: string): string | undefined {
  return STRUCTURES.find((s) => s.key === key)?.directive;
}

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

cd /Users/joshua/Projects/Unicorn.Land
git add oannes-engine/src/script/structures.ts oannes-engine/tests/structures.test.ts
git commit -m "feat(oannes-engine): narration structure archetypes"

Task 3: Inject structure directive into the reel prompt

Files: - Modify: oannes-engine/src/script/reelscript.ts (buildReelPrompt, writeReelScript) - Test: oannes-engine/tests/reelscript-structure.test.ts

buildReelPrompt takes an optional structureDir (the directive string, not the key — keeps reelscript.ts decoupled from the structures module). writeReelScript threads it through.

Create oannes-engine/tests/reelscript-structure.test.ts:

import { describe, it, expect } from "vitest";
import { buildReelPrompt } from "../src/script/reelscript.js";
import type { ContentBrief } from "../src/types.js";

const brief: ContentBrief = {
  claimId: "c1",
  claim: { claimText: "The soul is plasma.", claimant: "Jane Doe", topics: ["consciousness"] },
} as ContentBrief;

describe("buildReelPrompt structure injection", () => {
  it("includes the structure directive when provided", () => {
    const p = buildReelPrompt(brief, "wonder", undefined, "COLD OPEN: do the thing.");
    expect(p).toContain("NARRATION STRUCTURE for this video: COLD OPEN: do the thing.");
  });
  it("omits the structure block when not provided", () => {
    const p = buildReelPrompt(brief, "wonder");
    expect(p).not.toContain("NARRATION STRUCTURE");
  });
});

Run: cd oannes-engine && npx vitest run tests/reelscript-structure.test.ts Expected: FAIL — buildReelPrompt accepts 3 args / no structure block emitted.

In oannes-engine/src/script/reelscript.ts, change the signature and add the block. Replace the buildReelPrompt signature line:

export function buildReelPrompt(brief: ContentBrief, vibe: Vibe, guidance?: ScriptGuidance, structureDir?: string): string {

Immediately after the existing if (guidance) { ... } block and before lines.push("HARD RULES:", ...), insert:

  if (structureDir) {
    lines.push(`NARRATION STRUCTURE for this video: ${structureDir}`);
  }

Then update writeReelScript to accept and pass it. Replace its signature and body:

export async function writeReelScript(
  brief: ContentBrief,
  vibe: Vibe,
  run: RunClaude = runClaudeCli,
  guidance?: ScriptGuidance,
  structureDir?: string,
): Promise<ReelScript> {
  const raw = await run(buildReelPrompt(brief, vibe, guidance, structureDir));
  return ReelSchema.parse(parseFencedJson(raw)) as ReelScript;
}

Run: cd oannes-engine && npx vitest run tests/reelscript-structure.test.ts tests/reelscript-field.test.ts Expected: PASS (new file + existing field test still green — the new param is optional).

cd /Users/joshua/Projects/Unicorn.Land
git add oannes-engine/src/script/reelscript.ts oannes-engine/tests/reelscript-structure.test.ts
git commit -m "feat(oannes-engine): inject narration structure into reel prompt"

Task 4: Rotation picks fresh voice + structure (with learning bias)

Files: - Modify: oannes-engine/src/auto/rotate.ts (RotationState, planTodayLearned, advance) - Modify: oannes-engine/src/metrics/apply.ts (blendedDimensionScores dim union only) - Test: oannes-engine/tests/rotate-variety.test.ts

Voice/structure are global candidate pools (not vibe-dependent). Selection reuses the existing biasArtKey (already generic: fresh-candidate epsilon-greedy by score; empty scores → first fresh).

In oannes-engine/src/metrics/apply.ts, change the blendedDimensionScores signature’s dim type:

export function blendedDimensionScores(
  weights: WeightsFile | null, dim: "vibe" | "artKey" | "topic" | "voice" | "structure", K: number,
): Record<string, number> {

(Body unchanged — pw[dim] already indexes generically.) This compiles only after Task 6 adds voice/structure to PlatformWeights; for now it is a type-level widening that still references existing keys, so npx tsc --noEmit will error on pw[dim] for the new members. To keep this task self-contained, do Step 1 of Task 6 (add the two fields to the PlatformWeights interface + schema + aggregate) together with this task — see note below. The rest of Task 6 (tests) can follow.

Sequencing note: Tasks 4 and 6 share the PlatformWeights interface. Implement them as one commit if your worker keeps the suite green per commit: add the voice/structure fields to PlatformWeights (Task 6 Step 3) before running tsc here. The plan lists them separately for clarity; combine the commits if needed.

Create oannes-engine/tests/rotate-variety.test.ts:

import { describe, it, expect } from "vitest";
import { planTodayLearned, advance, type RotationState } from "../src/auto/rotate.js";
import { VOICE_KEYS } from "../src/media/voices.js";
import { STRUCTURE_KEYS } from "../src/script/structures.js";

describe("voice + structure rotation", () => {
  it("planTodayLearned returns a valid voice and structure with null weights", () => {
    const plan = planTodayLearned({ recentArt: [] }, null, () => 0.5, 0);
    expect(VOICE_KEYS).toContain(plan.voiceKey);
    expect(plan.voiceId).toBeTruthy();
    expect(STRUCTURE_KEYS).toContain(plan.structure);
  });

  it("avoids the most-recent voice and structure (no consecutive repeat)", () => {
    const state: RotationState = { recentArt: [], recentVoices: ["ash"], recentStructures: ["cold_open"] };
    // epsilon 0 → exploit; empty scores → first FRESH candidate
    const plan = planTodayLearned(state, null, () => 0.99, 0);
    expect(plan.voiceKey).not.toBe("ash");
    expect(plan.structure).not.toBe("cold_open");
  });

  it("advance tracks recentVoices and recentStructures, capped at 3", () => {
    let s: RotationState = { recentArt: [] };
    s = advance(s, "wonder", "energy", "ash", "cold_open");
    s = advance(s, "believer", "craft", "brian", "witness");
    s = advance(s, "wonder", "cosmic", "george", "contrarian");
    s = advance(s, "believer", "ruins", "bill", "escalation");
    expect(s.recentVoices).toEqual(["bill", "george", "brian"]);
    expect(s.recentStructures).toEqual(["escalation", "contrarian", "witness"]);
  });

  it("advance without voice/structure args leaves those lists untouched (back-compat)", () => {
    const s = advance({ recentArt: [], recentVoices: ["ash"], recentStructures: ["witness"] }, "wonder", "energy");
    expect(s.recentVoices).toEqual(["ash"]);
    expect(s.recentStructures).toEqual(["witness"]);
  });
});

Run: cd oannes-engine && npx vitest run tests/rotate-variety.test.ts Expected: FAIL — planTodayLearned has no voiceKey/voiceId/structure; advance ignores extra args.

In oannes-engine/src/auto/rotate.ts:

(a) Add imports at the top:

import { VOICE_KEYS, voiceIdForKey } from "../media/voices.js";
import { STRUCTURE_KEYS } from "../script/structures.js";

(b) Extend RotationState:

export interface RotationState {
  lastVibe?: Vibe;
  recentArt: string[]; // most-recent-first, capped
  recentVoices?: string[];     // most-recent-first, capped (optional for back-compat)
  recentStructures?: string[]; // most-recent-first, capped
}

(c) Replace advance with the four-recency version (small helper keeps it DRY):

function cap3(key: string, recent: string[]): string[] {
  return [key, ...recent.filter((k) => k !== key)].slice(0, 3);
}

/** Advance rotation state after a successful generate (cap each recency list at 3). */
export function advance(
  state: RotationState, vibe: Vibe, artKey: string, voiceKey?: string, structure?: string,
): RotationState {
  return {
    lastVibe: vibe,
    recentArt: cap3(artKey, state.recentArt),
    recentVoices: voiceKey ? cap3(voiceKey, state.recentVoices ?? []) : (state.recentVoices ?? []),
    recentStructures: structure ? cap3(structure, state.recentStructures ?? []) : (state.recentStructures ?? []),
  };
}

(d) Replace planTodayLearned so it also returns voice + structure. Keep the existing vibe/art logic; append voice/structure via biasArtKey (empty scores when no signal → first fresh):

export function planTodayLearned(
  state: RotationState, weights: WeightsFile | null,
  rng: () => number = Math.random, epsilon: number = EPSILON,
): { vibe: Vibe; art: ArtDirection; voiceKey: string; voiceId: string; structure: string } {
  const totalSamples = weights
    ? weights.instagram.sampleSize + weights.facebook.sampleSize + weights.youtube.sampleSize + weights.tiktok.sampleSize
    : 0;
  const hasSignal = !!weights && totalSamples > 0;

  // vibe + art: unchanged behavior (alternation/no-repeat when no signal)
  let vibe: Vibe, art: ArtDirection;
  if (!hasSignal) {
    ({ vibe, art } = planToday(state));
  } else {
    const vibeScores = blendedDimensionScores(weights!, "vibe", CONFIDENCE_K);
    vibe = Object.keys(vibeScores).length
      ? pickVibeWithExploration(vibeScores, VIBE_CANDIDATES, rng, epsilon)
      : nextVibe(state);
    const artScores = blendedDimensionScores(weights!, "artKey", CONFIDENCE_K);
    const artKey = biasArtKey(VIBE_ART[vibe], state.recentArt, artScores, rng, epsilon);
    art = ART_DIRECTIONS[artKey];
  }

  // voice + structure: global pools, fresh-candidate epsilon-greedy (empty scores → first fresh)
  const voiceScores = hasSignal ? blendedDimensionScores(weights!, "voice", CONFIDENCE_K) : {};
  const voiceKey = biasArtKey(VOICE_KEYS, state.recentVoices ?? [], voiceScores, rng, epsilon);
  const structScores = hasSignal ? blendedDimensionScores(weights!, "structure", CONFIDENCE_K) : {};
  const structure = biasArtKey(STRUCTURE_KEYS, state.recentStructures ?? [], structScores, rng, epsilon);

  return { vibe, art, voiceKey, voiceId: voiceIdForKey(voiceKey), structure };
}

Note: ART_DIRECTIONS and ArtDirection are already imported at the top of rotate.ts; planToday, nextVibe, pickVibeWithExploration, biasArtKey, blendedDimensionScores, CONFIDENCE_K, EPSILON, VIBE_CANDIDATES, VIBE_ART are already defined in the file.

Run: cd oannes-engine && npx vitest run tests/rotate-variety.test.ts tests/rotate.test.ts tests/rotate-learned.test.ts Expected: PASS. (Existing rotate.test.ts / rotate-learned.test.ts unchanged — advance‘s new params are optional and planTodayLearned still returns {vibe, art} plus extras.)

If rotate-learned.test.ts fails to compile because its emptyPW() helper lacks voice/structure, that means Task 6’s PlatformWeights change landed — update emptyPW to ({ vibe: {}, artKey: {}, topic: {}, format: {}, voice: {}, structure: {}, sampleSize: 0 }). See Task 6.

cd /Users/joshua/Projects/Unicorn.Land
git add oannes-engine/src/auto/rotate.ts oannes-engine/src/metrics/apply.ts oannes-engine/tests/rotate-variety.test.ts
git commit -m "feat(oannes-engine): rotate voice + structure per post (learnable)"

Task 5: Persist voice + structure on the metrics snapshot (store)

Files: - Modify: oannes-engine/src/metrics/store.ts (MetricSnapshot, CREATE_TABLE_SQL, MIGRATE_SQL, buildInsert, fetchLatestSnapshots) - Modify: oannes-engine/src/metrics/collect.ts (populate the two fields) - Modify test literals: oannes-engine/tests/metrics-store.test.ts, oannes-engine/tests/metrics-collect.test.ts, oannes-engine/tests/autostate.test.ts (any that construct a MetricSnapshot) - Test: extend oannes-engine/tests/metrics-store.test.ts

MetricSnapshot.voice/structure are required (consistent with vibe/artKey/format). This breaks every MetricSnapshot literal and collect.ts‘s construction until updated — all in this one commit.

In oannes-engine/tests/metrics-store.test.ts, add voice/structure to the existing snap literal:

const snap: MetricSnapshot = {
  claimId: "clm-1", platform: "instagram", publishedPostId: "pp_1",
  url: "https://ig/abc", vibe: "wonder", artKey: "energy", format: "carousel", hook: "Is mind quantum?",
  topic: "consciousness", voice: "brian", structure: "witness",
  metrics: { views: 1000, likes: 50, comments: 4, shares: 2, saves: 7, reach: 800, watchTimeSec: null },
  stickiness: 0.09, recordedAt: new Date("2026-06-16T12:00:00Z"),
};

And add a new assertion inside the buildInsert describe:

  it("includes voice and structure columns", () => {
    const { text, values } = buildInsert(snap);
    expect(text).toContain("voice");
    expect(text).toContain("structure");
    expect(values).toContain("brian");
    expect(values).toContain("witness");
  });

  it("MIGRATE_SQL adds voice/structure columns and their indexes", () => {
    expect(MIGRATE_SQL).toContain("ADD COLUMN IF NOT EXISTS voice");
    expect(MIGRATE_SQL).toContain("ADD COLUMN IF NOT EXISTS structure");
    expect(MIGRATE_SQL).toContain("idx_opm_platform_voice");
    expect(MIGRATE_SQL).toContain("idx_opm_platform_structure");
  });

Add MIGRATE_SQL to the import line:

import { buildInsert, CREATE_TABLE_SQL, MIGRATE_SQL, type MetricSnapshot } from "../src/metrics/store.js";

Run: cd oannes-engine && npx vitest run tests/metrics-store.test.ts Expected: FAIL — voice/structure not on MetricSnapshot / not in insert / not in MIGRATE_SQL.

In oannes-engine/src/metrics/store.ts:

(a) Add to the MetricSnapshot interface (after topic):

  voice: string;     // reel voice key ("" for carousel — excluded from learning)
  structure: string; // reel narration structure key ("" for carousel)

(b) In CREATE_TABLE_SQL, add the two columns (NO index here — fresh-install columns only). Change the column block to include them after topic text,:

  topic text,
  voice text,
  structure text,

(c) Replace MIGRATE_SQL so it also adds the new columns + their indexes (indexes live here, after the column exists — the lesson from the format bug):

export const MIGRATE_SQL = `
ALTER TABLE oannes_post_metrics ADD COLUMN IF NOT EXISTS format text;
ALTER TABLE oannes_post_metrics ADD COLUMN IF NOT EXISTS voice text;
ALTER TABLE oannes_post_metrics ADD COLUMN IF NOT EXISTS structure text;
CREATE INDEX IF NOT EXISTS idx_opm_platform_format ON oannes_post_metrics (platform, format);
CREATE INDEX IF NOT EXISTS idx_opm_platform_voice ON oannes_post_metrics (platform, voice);
CREATE INDEX IF NOT EXISTS idx_opm_platform_structure ON oannes_post_metrics (platform, structure);
`;

(d) Update buildInsert to include the two columns ($20, $21). Replace the text and values:

  const text = `INSERT INTO oannes_post_metrics
    (id, claim_id, platform, published_post_id, url, vibe, art_key, format, hook, topic, voice, structure,
     views, likes, comments, shares, saves, reach, watch_time_sec, stickiness, recorded_at)
    VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21)
    ON CONFLICT (id) DO NOTHING`;
  const m = s.metrics;
  const values = [id, s.claimId, s.platform, s.publishedPostId, s.url, s.vibe, s.artKey, s.format, s.hook, s.topic, s.voice, s.structure,
    m.views, m.likes, m.comments, m.shares, m.saves, m.reach, m.watchTimeSec, s.stickiness, s.recordedAt];

(e) In fetchLatestSnapshots, map the two columns (after topic):

    voice: (r.voice as string) ?? "",
    structure: (r.structure as string) ?? "",

In oannes-engine/src/metrics/collect.ts, the snapshot is built per result. Reels carry the item’s voice/structure; carousels record "". Replace the snap literal:

      const isCarousel = res.format === "carousel";
      const snap: MetricSnapshot = {
        claimId: item.claimId, platform, publishedPostId: match.id, url: res.url,
        vibe: item.vibe, artKey: item.artKey,
        format: (res.format as "reel" | "carousel") ?? "reel",
        hook: item.hook ?? "", topic: item.topic ?? "",
        voice: isCarousel ? "" : (item.voice ?? ""),
        structure: isCarousel ? "" : (item.structure ?? ""),
        metrics, stickiness: stickiness(platform, metrics), recordedAt,
      };

(item.voice/item.structure come from PendingItem — added in Task 7. They’re optional there, so ?? "" is safe and this compiles now.)

Search and fix every remaining MetricSnapshot literal so the suite compiles:

Run: cd oannes-engine && grep -rl "MetricSnapshot" tests/

For each literal found in tests/metrics-collect.test.ts, tests/autostate.test.ts, tests/metrics-apply.test.ts, tests/rotate-learned.test.ts, tests/metrics-analyze.test.ts that builds a MetricSnapshot (look for objects with claimId, platform, vibe, artKey), add voice: "", structure: "" (or representative values where the test asserts on them). Any helper that builds snapshots should add the two fields once.

Run: cd oannes-engine && npm test Expected: PASS (tsc clean + all vitest green). Fix any remaining literal compile errors flagged by tsc.

cd /Users/joshua/Projects/Unicorn.Land
git add oannes-engine/src/metrics/store.ts oannes-engine/src/metrics/collect.ts oannes-engine/tests/
git commit -m "feat(oannes-engine): store voice + structure on metrics snapshots"

Task 6: Aggregate voice + structure into weights (analyze)

Files: - Modify: oannes-engine/src/metrics/analyze.ts (PlatformWeights, aggregate, PlatformWeightsSchema) - Modify test literals/helpers: oannes-engine/tests/rotate-learned.test.ts, oannes-engine/tests/metrics-apply.test.ts, oannes-engine/tests/metrics-analyze.test.ts - Test: extend oannes-engine/tests/metrics-analyze.test.ts

If you combined this with Task 4 (recommended for green-per-commit), the interface/schema/aggregate changes are already done — only Steps 1–2 (tests) remain.

Add to oannes-engine/tests/metrics-analyze.test.ts:

import { aggregate, readWeightsFile } from "../src/metrics/analyze.js";
// (reuse existing imports/helpers in the file; below assumes a helper that builds snapshots)

describe("voice + structure aggregation", () => {
  it("computes per-platform mean stickiness for voice and structure, skipping empties", () => {
    const now = new Date("2026-06-27T00:00:00Z");
    const base = {
      claimId: "c", publishedPostId: "p", url: "u", vibe: "wonder", artKey: "energy",
      format: "reel" as const, hook: "h", topic: "t", recordedAt: now,
      metrics: { views: 100, likes: 1, comments: 0, shares: 0, saves: 0, reach: null, watchTimeSec: null },
    };
    const rows = [
      { ...base, platform: "youtube", voice: "brian", structure: "witness", stickiness: 0.8 },
      { ...base, platform: "youtube", voice: "brian", structure: "witness", stickiness: 0.4 },
      { ...base, platform: "youtube", voice: "", structure: "", stickiness: 0.9 }, // carousel-style: excluded
    ];
    const w = aggregate(rows as any, now);
    expect(w.youtube.voice.brian).toBeCloseTo(0.6);     // (0.8+0.4)/2
    expect(w.youtube.voice[""]).toBeUndefined();        // empty key skipped
    expect(w.youtube.structure.witness).toBeCloseTo(0.6);
  });

  it("readWeightsFile parses a legacy weights file with no voice/structure", async () => {
    const fs = await import("node:fs/promises");
    const os = await import("node:os");
    const path = await import("node:path");
    const f = path.join(os.tmpdir(), "legacy-weights.json");
    const legacy = {
      instagram: { vibe: {}, artKey: {}, topic: {}, format: {}, sampleSize: 0 },
      facebook: { vibe: {}, artKey: {}, topic: {}, format: {}, sampleSize: 0 },
      youtube: { vibe: {}, artKey: {}, topic: {}, format: {}, sampleSize: 0 },
      tiktok: { vibe: {}, artKey: {}, topic: {}, format: {}, sampleSize: 0 },
      updatedAt: "x",
    };
    await fs.writeFile(f, JSON.stringify(legacy));
    const parsed = await readWeightsFile(f);
    expect(parsed?.youtube.voice).toEqual({});       // defaulted
    expect(parsed?.youtube.structure).toEqual({});
  });
});

Run: cd oannes-engine && npx vitest run tests/metrics-analyze.test.ts Expected: FAIL — voice/structure not on PlatformWeights.

(a) Extend PlatformWeights:

export interface PlatformWeights {
  vibe: Record<string, number>;
  artKey: Record<string, number>;
  topic: Record<string, number>;
  format: Record<string, number>;
  voice: Record<string, number>;
  structure: Record<string, number>;
  sampleSize: number;
}

(b) In aggregate, add the two maps to each platform’s object (after topic):

      voice: meanByKey(scored, (r) => r.voice),
      structure: meanByKey(scored, (r) => r.structure),

(c) Extend PlatformWeightsSchema with back-compat defaults (after format):

  voice: z.record(z.number()).default({}),
  structure: z.record(z.number()).default({}),

Update every PlatformWeights literal so the suite compiles. Specifically: - tests/rotate-learned.test.ts: change emptyPW to () => ({ vibe: {}, artKey: {}, topic: {}, format: {}, voice: {}, structure: {}, sampleSize: 0 }), and add voice: {}, structure: {} to the inline youtube: { vibe: {...}, artKey: {}, topic: {}, format: {}, sampleSize: 10 } literal. - tests/metrics-apply.test.ts: add voice: {}, structure: {} to each inline PlatformWeights literal (search for sampleSize). - tests/metrics-analyze.test.ts: the legacy-parse test above intentionally omits them (testing defaults) — leave that one as-is; fix any other full literals.

Run: cd oannes-engine && npm test Expected: PASS (tsc clean + all green).

cd /Users/joshua/Projects/Unicorn.Land
git add oannes-engine/src/metrics/analyze.ts oannes-engine/tests/
git commit -m "feat(oannes-engine): aggregate voice + structure into learned weights"

Task 7: PendingItem carries voice + structure (state)

Files: - Modify: oannes-engine/src/auto/state.ts (PendingItem) - Test: existing oannes-engine/tests/autostate.test.ts (extend if it asserts on PendingItem shape; otherwise no test change)

In oannes-engine/src/auto/state.ts, add to PendingItem (after the topic? field, keeping them optional so older queued items load):

  // Per-post variety tags (reel VO voice key + narration structure key):
  voice?: string;
  structure?: string;

Run: cd oannes-engine && npm test Expected: PASS (additive optional fields; collect.ts‘s item.voice ?? "" now type-resolves).

cd /Users/joshua/Projects/Unicorn.Land
git add oannes-engine/src/auto/state.ts
git commit -m "feat(oannes-engine): PendingItem carries voice + structure tags"

Task 8: Wire daily generation (voice TTS, structure script, record, preview)

Files: - Modify: oannes-engine/src/auto/daily.ts

This is integration wiring of the pieces from Tasks 1–7. No new unit test (the units are tested); verified by tsc + a manual dry generate in Task 9.

(a) Add imports near the top:

import { structureDirective } from "../script/structures.js";
import { voiceLabel } from "../media/voices.js";

(b) Replace the plan destructure + log line:

  const { vibe, art, voiceKey, voiceId, structure } = planTodayLearned(state.rotation, weights);
  console.log(`today: vibe=${vibe} art=${art.label} voice=${voiceKey} structure=${structure}${weights ? " (learned)" : ""}`);

(c) Pass the structure directive into the reel script. Replace the writeReelScript call:

  const script = await writeReelScript(brief, vibe, undefined, guidance, structureDirective(structure));

(d) Use the chosen voice for TTS. Replace the synthesizeVoiceoverTimed call’s voiceId:

  const timed = await synthesizeVoiceoverTimed({ apiKey: cfg.keys.elevenlabs!, voiceId, text: script.narration, outPath: audioPath });

(e) Add a voice/structure line to the Discord preview content array. Insert after the existing _${vibe}_ · ${art.label} header line:

    `🎙️ ${voiceLabel(voiceKey)} · 🧬 ${structure}`,

(f) Record voice + structure on the PendingItem. In the item literal, add (after topic):

    voice: voiceKey, structure,

(g) Pass them to advance. Replace the advance line:

  state.rotation = advance(state.rotation, vibe, art.key, voiceKey, structure);

Run: cd oannes-engine && npx tsc --noEmit Expected: no errors.

Run: cd oannes-engine && npm test Expected: PASS.

cd /Users/joshua/Projects/Unicorn.Land
git add oannes-engine/src/auto/daily.ts
git commit -m "feat(oannes-engine): wire per-post voice + structure into daily generation"

Task 9: Live verification (dry generate + DB migration check)

Files: none (verification only)

The metrics table lives in Cambium Postgres. Confirm MIGRATE_SQL applies cleanly (idempotent ADD COLUMN IF NOT EXISTS). The collector runs ensureTable (which runs CREATE_TABLE_SQL then MIGRATE_SQL) on next collect-metrics. To pre-verify without waiting for the cron, run the collector once:

Run: cd oannes-engine && bash -lc 'export PATH=/Users/joshua/.nvm/versions/node/v22.22.0/bin:$PATH && npm run engine collect-metrics' Expected: completes without SQL error; new voice/structure columns exist afterward. (If DATABASE_URL/Cambium is unreachable in this environment, note it and defer to the next scheduled run — the migration is IF NOT EXISTS-safe.)

Run: cd oannes-engine && bash -lc 'export PATH=/Users/joshua/.nvm/versions/node/v22.22.0/bin:$PATH && DRY_RUN=true npm run engine generate-daily' (or the project’s standard generate invocation) Expected console line: today: vibe=… art=… voice=… structure=… with a voice key from VOICE_KEYS and a structure key from STRUCTURE_KEYS. Confirm the Discord preview (if it posts) shows the 🎙️ … · 🧬 … line.

If generate-daily always publishes/posts to Discord and you don’t want a live post during verification, instead assert the wiring by inspecting renders/auto/state.json after a run, or run the existing test suite only and rely on Task 8’s typecheck.

Run: cd oannes-engine && npm test Expected: PASS — full green, tsc clean.


Self-Review

Spec coverage: - Voice pool (spec §1) → Task 1. ✓ - Structure archetypes (spec §2) → Task 2. ✓ - Rotation + learning, planTodayLearned return, dim union (spec §3) → Task 4. ✓ - Script + generation wiring (spec §4) → Tasks 3 (prompt) + 8 (daily). ✓ - Metrics plumbing: store columns/migration, collect reel-vs-carousel “”, analyze maps + schema defaults (spec §5) → Tasks 5 + 6. ✓ - State PendingItem (spec §6) → Task 7. ✓ - Discord preview line (spec §7) → Task 8 Step 1(e). ✓ - Tests enumerated (spec Testing) → covered across Tasks 1–6. ✓ - Migration risk mitigation (indexes only in MIGRATE_SQL) → Task 5 Step 3(b)(c). ✓

Placeholder scan: No TBD/TODO; every code step shows complete code. The only conditional is the Task 4/6 sequencing note (shared PlatformWeights interface) — explicitly instructed to combine commits to keep compile green.

Type consistency: voiceKey/voiceId/structure names consistent across rotate → daily → PendingItem → collect → store. MetricSnapshot.voice/structure (required) vs PendingItem.voice/structure (optional) — bridged by item.voice ?? "" in collect (Task 5 Step 4). blendedDimensionScores dim union (Task 4 Step 1) matches PlatformWeights keys (Task 6 Step 3). advance new params optional → existing calls/tests unaffected.