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
- [ ] Step 1: Write the failing test
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");
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/voices.test.ts
Expected: FAIL — cannot find module ../src/media/voices.js.
- [ ] Step 3: Write the implementation
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;
}
- [ ] Step 4: Run test to verify it passes
Run: cd oannes-engine && npx vitest run tests/voices.test.ts
Expected: PASS (3 tests).
- [ ] Step 5: Commit
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
- [ ] Step 1: Write the failing test
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();
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/structures.test.ts
Expected: FAIL — cannot find module ../src/script/structures.js.
- [ ] Step 3: Write the implementation
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;
}
- [ ] Step 4: Run test to verify it passes
Run: cd oannes-engine && npx vitest run tests/structures.test.ts
Expected: PASS (3 tests).
- [ ] Step 5: Commit
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.
- [ ] Step 1: Write the failing test
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");
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/reelscript-structure.test.ts
Expected: FAIL — buildReelPrompt accepts 3 args / no structure block emitted.
- [ ] Step 3: Implement
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;
}
- [ ] Step 4: Run tests to verify they pass
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).
- [ ] Step 5: Commit
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).
- [ ] Step 1: Extend the dim union in apply.ts
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
PlatformWeightsinterface. Implement them as one commit if your worker keeps the suite green per commit: add thevoice/structurefields toPlatformWeights(Task 6 Step 3) before runningtschere. The plan lists them separately for clarity; combine the commits if needed.
- [ ] Step 2: Write the failing test
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"]);
});
});
- [ ] Step 3: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/rotate-variety.test.ts
Expected: FAIL — planTodayLearned has no voiceKey/voiceId/structure; advance ignores extra args.
- [ ] Step 4: Implement rotate.ts
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.
- [ ] Step 5: Run tests to verify they pass
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.tsfails to compile because itsemptyPW()helper lacksvoice/structure, that means Task 6’sPlatformWeightschange landed — updateemptyPWto({ vibe: {}, artKey: {}, topic: {}, format: {}, voice: {}, structure: {}, sampleSize: 0 }). See Task 6.
- [ ] Step 6: Commit
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.
- [ ] Step 1: Write the failing test (extend metrics-store.test.ts)
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";
- [ ] Step 2: Run test to verify it fails
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.
- [ ] Step 3: Implement store.ts
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) ?? "",
- [ ] Step 4: Implement collect.ts (populate the fields)
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.)
- [ ] Step 5: Update other MetricSnapshot literals in tests
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.
- [ ] Step 6: Run the full suite to verify green
Run: cd oannes-engine && npm test
Expected: PASS (tsc clean + all vitest green). Fix any remaining literal compile errors flagged by tsc.
- [ ] Step 7: Commit
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.
- [ ] Step 1: Write the failing test (extend metrics-analyze.test.ts)
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({});
});
});
- [ ] Step 2: Run test to verify it fails
Run: cd oannes-engine && npx vitest run tests/metrics-analyze.test.ts
Expected: FAIL — voice/structure not on PlatformWeights.
- [ ] Step 3: Implement analyze.ts
(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({}),
- [ ] Step 4: Update PlatformWeights literals/helpers in tests
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.
- [ ] Step 5: Run the full suite
Run: cd oannes-engine && npm test
Expected: PASS (tsc clean + all green).
- [ ] Step 6: Commit
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)
- [ ] Step 1: Implement
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;
- [ ] Step 2: Run the suite (type check)
Run: cd oannes-engine && npm test
Expected: PASS (additive optional fields; collect.ts‘s item.voice ?? "" now type-resolves).
- [ ] Step 3: Commit
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.
- [ ] Step 1: Implement daily.ts changes
(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);
- [ ] Step 2: Typecheck
Run: cd oannes-engine && npx tsc --noEmit
Expected: no errors.
- [ ] Step 3: Run the full suite
Run: cd oannes-engine && npm test
Expected: PASS.
- [ ] Step 4: Commit
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)
- [ ] Step 1: Verify the migration against the live Cambium table
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.)
- [ ] Step 2: Dry generate one drop to confirm rotation + script wiring
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-dailyalways publishes/posts to Discord and you don’t want a live post during verification, instead assert the wiring by inspectingrenders/auto/state.jsonafter a run, or run the existing test suite only and rely on Task 8’s typecheck.
- [ ] Step 3: Final full suite
Run: cd oannes-engine && npm test
Expected: PASS — full green, tsc clean.
- [ ] Step 4: No commit (verification only). If Step 1/2 surfaced a fix, commit it with a
fix(oannes-engine):message.
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.