weftens 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +6 -5
- package/agents/agent2060.md +10 -10
- package/agents/amazon-ppc.md +42 -42
- package/agents/claims-content.md +35 -35
- package/agents/content-ops.md +46 -46
- package/agents/growth-marketing.md +53 -0
- package/agents/hilbert.md +1 -1
- package/agents/lippmann.md +8 -8
- package/agents/meta-ads.md +38 -0
- package/agents/paid-search.md +34 -35
- package/agents/pio.md +4 -4
- package/agents/richy.md +11 -11
- package/agents/security-writer.md +65 -0
- package/agents/seo-content.md +57 -57
- package/agents/seo-technical.md +53 -53
- package/agents/social-voice.md +38 -38
- package/agents/supply-verify.md +38 -38
- package/agents/writer.md +6 -6
- package/package.json +4 -3
- package/src/invoke.js +10 -2
- package/content-org/.preflight/telemetry.jsonl +0 -6
- package/content-org/briefs/BRIEFS.md +0 -47
- package/content-org/config.mjs +0 -78
- package/content-org/cost.mjs +0 -36
- package/content-org/lead.js +0 -64
- package/content-org/metrics.mjs +0 -166
- package/content-org/orchestrator.mjs +0 -246
- package/content-org/package.json +0 -14
- package/content-org/producer.mjs +0 -59
- package/content-org/publish.mjs +0 -34
- package/content-org/review.mjs +0 -32
- package/content-org/studio/README.md +0 -27
- package/content-org/studio/assemble.sh +0 -48
- package/content-org/test.mjs +0 -110
|
@@ -1,47 +0,0 @@
|
|
|
1
|
-
# Format briefs
|
|
2
|
-
|
|
3
|
-
The five formats the org produces. Each is a **brief the seats consume** — angle patterns, structure,
|
|
4
|
-
and the disclosure stance. `RISK` in `config.mjs` sets which auto-post vs hold. Audience across all:
|
|
5
|
-
**older Facebook users**; brand voice: *declare what's real*; disclosure is a **per-piece call, never an
|
|
6
|
-
always-rule**; the scam guardrail is **theatre** (instant reveal, captures nothing, no real person harmed).
|
|
7
|
-
|
|
8
|
-
---
|
|
9
|
-
|
|
10
|
-
## traveler · auto
|
|
11
|
-
The recurring time-traveler character dropped into a different time+place each episode. **The moat** —
|
|
12
|
-
people subscribe to a *who*, not a vibe. Needs character-consistent clips (Veo/Seedance hero lane).
|
|
13
|
-
- **Hook (3s):** the character mid-scene in a vivid era ("It's Tokyo, 1985, and…").
|
|
14
|
-
- **Structure:** cold open in-scene → one wistful observation that ties past to now → button line.
|
|
15
|
-
- **Disclosure:** "Imagined with AI" when it fits; sometimes the era-craft *is* the point. Kayla's call.
|
|
16
|
-
- **Continuity:** same face, voice, and one signature line across episodes.
|
|
17
|
-
|
|
18
|
-
## scam-reveal · hold
|
|
19
|
-
Nostalgia/relatable hook → theatrical "attempted hack" → **"YOU COULD HAVE BEEN HACKED"** → one defensive tell.
|
|
20
|
-
- **Hook (3s):** a familiar scenario (a text from "your bank," a too-good deal).
|
|
21
|
-
- **Structure:** set the trap → the reveal beat → one concrete thing the viewer can do tomorrow.
|
|
22
|
-
- **HARD LINE:** theatre only. Instant reveal, page/piece captures NOTHING, no real non-consenting person
|
|
23
|
-
is hacked or harmed. QC blocks anything that crosses this.
|
|
24
|
-
- **Disclosure:** the reveal is the disclosure; label as AI where useful.
|
|
25
|
-
|
|
26
|
-
## is-this-real · hold
|
|
27
|
-
Teach older viewers to spot AI fakes / deepfakes / cloned voices. **Most on-brand** — the content *is* the
|
|
28
|
-
"declare what's real" demo.
|
|
29
|
-
- **Hook (3s):** "Is this real? Watch closely." (a clip with one tell).
|
|
30
|
-
- **Structure:** show the fake → reveal the tell(s) → "here's how to check next time."
|
|
31
|
-
- **Disclosure:** always clarify what was AI vs real in-piece — here it's the whole point.
|
|
32
|
-
- **QC:** claim accuracy is critical; never mislabel a real thing as fake or vice-versa.
|
|
33
|
-
|
|
34
|
-
## nostalgia · auto
|
|
35
|
-
Straight "remember when" archival-feeling reach. Pure audience fuel; highest raw engagement with older FB.
|
|
36
|
-
- **Hook (3s):** a specific sensory memory ("If you remember the sound of a rotary phone…").
|
|
37
|
-
- **Structure:** montage of era details → one warm line of meaning → gentle prompt to comment.
|
|
38
|
-
- **Disclosure:** label as AI-imagined where the footage is fabricated (Meta throttles undisclosed
|
|
39
|
-
fake-real-events — proactive labeling protects reach).
|
|
40
|
-
- **Bulk lane** (Wan) is fine; no recurring face needed.
|
|
41
|
-
|
|
42
|
-
## ai-for-seniors · hold
|
|
43
|
-
Calm, plain-language explainers ("what's an agent?", "is the AI listening to me?"). Empowerment, no fear.
|
|
44
|
-
- **Hook (3s):** a real question they've wondered ("Is your phone spying on you? Sort of — here's the truth").
|
|
45
|
-
- **Structure:** the worry → the plain answer → one practical takeaway.
|
|
46
|
-
- **Disclosure:** AI-made, said plainly; builds the brand credibility the flywheel cashes in.
|
|
47
|
-
- **QC:** no false/overblown claims about AI; accuracy is the whole value.
|
package/content-org/config.mjs
DELETED
|
@@ -1,78 +0,0 @@
|
|
|
1
|
-
// content-org — central config. Change tiers/models here; nothing else needs editing.
|
|
2
|
-
|
|
3
|
-
export const MODELS = {
|
|
4
|
-
haiku: "claude-haiku-4-5", // $1/$5 per 1M — routine seats
|
|
5
|
-
sonnet: "claude-sonnet-5", // $3/$15 — writing-quality seats
|
|
6
|
-
opus: "claude-opus-4-8", // $5/$25 — only if a real test shows a lift
|
|
7
|
-
};
|
|
8
|
-
|
|
9
|
-
// Value tier (locked 2026-07-08): pay for quality only where it moves reach/moat.
|
|
10
|
-
// Sonnet on the seats whose prose the viewer feels (Strategy board, Script, Editor);
|
|
11
|
-
// Haiku on the structured hand-offs the viewer never sees.
|
|
12
|
-
export const SEAT_MODEL = {
|
|
13
|
-
showrunner: "haiku",
|
|
14
|
-
strategy: "sonnet",
|
|
15
|
-
ideator: "haiku",
|
|
16
|
-
research: "haiku",
|
|
17
|
-
script: "sonnet",
|
|
18
|
-
editor: "sonnet",
|
|
19
|
-
cut: "sonnet", // Video Editor — the cut decides completion rate; worth Sonnet
|
|
20
|
-
feed: "haiku",
|
|
21
|
-
qc: "haiku",
|
|
22
|
-
growth: "haiku", // Growth/Analyst — reads the deterministic metrics.mjs summary; structured hand-off, viewer never sees it
|
|
23
|
-
};
|
|
24
|
-
|
|
25
|
-
// Clip lanes — pick per piece. Prices are RANGES (sources disagree; price the exact
|
|
26
|
-
// tier on fal.ai at commit). No flat-unlimited *automated* lane exists (verified 2026-07-08).
|
|
27
|
-
export const CLIP_LANES = {
|
|
28
|
-
manual: { label: "manual flat sub", usdPerSec: 0, note: "hand-generate; ~$0 marginal; cheapest" },
|
|
29
|
-
bulk: { label: "Wan 2.6 (bulk B-roll)", usdPerSec: 0.06, audio: true },
|
|
30
|
-
hero: { label: "Veo 3.1 (hero / recurring face)", usdPerSec: 0.05, audio: true, note: "value king: audio + consistency" },
|
|
31
|
-
// Kling deliberately omitted from defaults — priciest, no consistency edge over Veo/Seedance.
|
|
32
|
-
};
|
|
33
|
-
|
|
34
|
-
// Approx token cost per finished piece by model mix (for cost.mjs). ~per-piece USD of the
|
|
35
|
-
// 8 text-seat calls; Batch API halves it. Rough, tune against real usage.
|
|
36
|
-
export const TEXT_COST_PER_PIECE = {
|
|
37
|
-
valueTier: 0.20, // Sonnet(strategy/script/editor) + Haiku(rest)
|
|
38
|
-
valueTierBatch: 0.10, // with Batch API (50% off)
|
|
39
|
-
allHaiku: 0.05,
|
|
40
|
-
allOpus: 0.55,
|
|
41
|
-
};
|
|
42
|
-
|
|
43
|
-
export const VOICE = {
|
|
44
|
-
elevenlabs: { label: "ElevenLabs Starter", usdPerMonth: 5, perPieceUsd: 0.03 },
|
|
45
|
-
kokoro: { label: "Kokoro (OSS, local)", usdPerMonth: 0, perPieceUsd: 0 },
|
|
46
|
-
veoNative: { label: "Veo native audio (no separate voice)", usdPerMonth: 0, perPieceUsd: 0 },
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
// Client-facing price tiers (DRAFT — Kayla to confirm numbers). reels = pieces/month.
|
|
50
|
-
export const CLIENT_TIERS = {
|
|
51
|
-
starter: {
|
|
52
|
-
name: "Starter", priceUsd: 399, reels: 8,
|
|
53
|
-
clip: "bulk", script: "sonnet", voice: "elevenlabs",
|
|
54
|
-
recurringCharacter: 0, managedPosting: false, complianceQC: "basic", revisions: 1,
|
|
55
|
-
},
|
|
56
|
-
growth: {
|
|
57
|
-
name: "Growth", priceUsd: 899, reels: 20,
|
|
58
|
-
clip: "hero+bulk", script: "sonnet", voice: "elevenlabs",
|
|
59
|
-
recurringCharacter: 1, managedPosting: true, complianceQC: "disclosed+claim-clean",
|
|
60
|
-
analytics: "monthly", revisions: 2,
|
|
61
|
-
},
|
|
62
|
-
signature: {
|
|
63
|
-
name: "Signature", priceUsd: 1999, reels: 40,
|
|
64
|
-
clip: "hero", script: "sonnet+opus-hero", voice: "elevenlabs",
|
|
65
|
-
recurringCharacter: 2, managedPosting: true, complianceQC: "senior/finance/health compliance pass",
|
|
66
|
-
analytics: "full+strategy", revisions: "generous",
|
|
67
|
-
},
|
|
68
|
-
alacarte: { name: "À la carte", priceUsd: 100, reels: 1, note: "$75–150/Reel for one-offs & tests" },
|
|
69
|
-
};
|
|
70
|
-
|
|
71
|
-
// Format → risk tier (tiered approval). Sensitive formats HOLD for Kayla.
|
|
72
|
-
export const RISK = {
|
|
73
|
-
"traveler": "auto",
|
|
74
|
-
"nostalgia": "auto",
|
|
75
|
-
"scam-reveal": "hold",
|
|
76
|
-
"is-this-real": "hold",
|
|
77
|
-
"ai-for-seniors": "hold",
|
|
78
|
-
};
|
package/content-org/cost.mjs
DELETED
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
// content-org — COGS + margin calculator. `node cost.mjs [piecesPerMonth]`
|
|
2
|
-
// No dependencies. Prices are estimates from config.mjs — tune against real usage.
|
|
3
|
-
import { CLIENT_TIERS, TEXT_COST_PER_PIECE, CLIP_LANES, VOICE } from "./config.mjs";
|
|
4
|
-
|
|
5
|
-
const usd = (n) => "$" + n.toFixed(2);
|
|
6
|
-
const REEL_SECONDS = 30; // ~30s Reel
|
|
7
|
-
|
|
8
|
-
// per-finished-Reel COGS given a clip lane + text tier + voice
|
|
9
|
-
function cogsPerReel({ clip = "hero", textTier = "valueTierBatch", voice = "elevenlabs" } = {}) {
|
|
10
|
-
const text = TEXT_COST_PER_PIECE[textTier] ?? TEXT_COST_PER_PIECE.valueTierBatch;
|
|
11
|
-
const clipUsd = (CLIP_LANES[clip]?.usdPerSec ?? 0) * REEL_SECONDS;
|
|
12
|
-
const voiceUsd = VOICE[voice]?.perPieceUsd ?? 0;
|
|
13
|
-
return text + clipUsd + voiceUsd;
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
console.log("=== per-Reel COGS by clip lane (value text tier + Batch, ElevenLabs voice) ===");
|
|
17
|
-
for (const lane of Object.keys(CLIP_LANES)) {
|
|
18
|
-
console.log(` ${lane.padEnd(7)} ${usd(cogsPerReel({ clip: lane }))} (${CLIP_LANES[lane].label})`);
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
console.log("\n=== client tiers (DRAFT prices) ===");
|
|
22
|
-
for (const key of ["starter", "growth", "signature"]) {
|
|
23
|
-
const t = CLIENT_TIERS[key];
|
|
24
|
-
const lane = key === "starter" ? "bulk" : "hero";
|
|
25
|
-
const cogs = cogsPerReel({ clip: lane }) * t.reels;
|
|
26
|
-
const voiceSub = VOICE.elevenlabs.usdPerMonth; // one sub covers all clients
|
|
27
|
-
const margin = t.priceUsd - cogs;
|
|
28
|
-
const marginPct = ((margin / t.priceUsd) * 100).toFixed(0);
|
|
29
|
-
console.log(` ${t.name.padEnd(10)} ${usd(t.priceUsd)}/mo ${String(t.reels).padStart(2)} Reels COGS ${usd(cogs)} → margin ${usd(margin)} (${marginPct}%)`);
|
|
30
|
-
}
|
|
31
|
-
console.log(` (+ shared fixed: ElevenLabs sub ${usd(VOICE.elevenlabs.usdPerMonth)}/mo across all clients; tooling floor ~$130/mo automated)`);
|
|
32
|
-
|
|
33
|
-
console.log("\n=== YOUR OWN pages ===");
|
|
34
|
-
const own = Number(process.argv[2] || "90");
|
|
35
|
-
const ownCogs = cogsPerReel({ clip: "hero" }) * own;
|
|
36
|
-
console.log(` ${own} Reels/mo automated (Veo hero): clips+text+voice ≈ ${usd(ownCogs)} | manual clips ≈ ${usd(cogsPerReel({ clip: "manual" }) * own)}`);
|
package/content-org/lead.js
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
// Content lead — the single callable entry the Weftens front door hands to when a
|
|
2
|
-
// request is about making content. The door calls this; this runs the 10-seat
|
|
3
|
-
// pipeline and returns one result. The door never addresses the individual seats
|
|
4
|
-
// (ideator, writer, qc, …) — the Content lead owns them.
|
|
5
|
-
//
|
|
6
|
-
// It shells the existing orchestrator rather than importing a run() function: the
|
|
7
|
-
// orchestrator's full-run body is argv/global-driven, and wrapping it by shell keeps
|
|
8
|
-
// its 27 passing tests untouched (no refactor risk). The orchestrator writes packet
|
|
9
|
-
// JSON into content-org/inbox/<date>/; we run it, read those packets back, and return
|
|
10
|
-
// them as the result.
|
|
11
|
-
//
|
|
12
|
-
// Defaults to dry-run. A real content run depends on Phase-0 decisions that are
|
|
13
|
-
// Kayla's and still open (local vs cloud, cadence, keys) — so this wiring proves the
|
|
14
|
-
// pipeline is callable as one node WITHOUT producing real published content. Pass
|
|
15
|
-
// { dry: false } only once those decisions are made and a key is configured.
|
|
16
|
-
|
|
17
|
-
import { execFile } from "node:child_process";
|
|
18
|
-
import { promisify } from "node:util";
|
|
19
|
-
import { readFile, readdir, rm } from "node:fs/promises";
|
|
20
|
-
import { fileURLToPath } from "node:url";
|
|
21
|
-
import { dirname, join } from "node:path";
|
|
22
|
-
|
|
23
|
-
const execFileP = promisify(execFile);
|
|
24
|
-
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Run the content pipeline as one node.
|
|
28
|
-
* @param opts { format?, count?, client?, tier?, dry? } // dry defaults true (safe)
|
|
29
|
-
* @returns { dry, format, count, artifacts, result, log }
|
|
30
|
-
* artifacts packet file paths produced
|
|
31
|
-
* result the parsed packets (dry-run packets are stub-shaped, clearly slugged dryrun-N)
|
|
32
|
-
*/
|
|
33
|
-
export async function runContent(opts = {}) {
|
|
34
|
-
const { format = "scam-reveal", count = 1, client = null, tier = null, dry = true } = opts;
|
|
35
|
-
|
|
36
|
-
const args = ["orchestrator.mjs", format, String(count)];
|
|
37
|
-
if (client) args.push(`--client=${client}`);
|
|
38
|
-
if (tier) args.push(`--tier=${tier}`);
|
|
39
|
-
if (dry) args.push("--dry-run");
|
|
40
|
-
|
|
41
|
-
const { stdout } = await execFileP("node", args, { cwd: HERE, maxBuffer: 8_000_000 });
|
|
42
|
-
|
|
43
|
-
// The orchestrator writes to inbox/<date>/; recover <date> from its own output line
|
|
44
|
-
// ("Packets in inbox/2026-07-19/.") rather than recomputing a clock here.
|
|
45
|
-
const m = stdout.match(/Packets in (inbox[\\/][^\s/]+)/);
|
|
46
|
-
const outRel = m ? m[1].replace(/[\\/]$/, "") : null;
|
|
47
|
-
const artifacts = [];
|
|
48
|
-
const result = [];
|
|
49
|
-
if (outRel) {
|
|
50
|
-
const outDir = join(HERE, outRel);
|
|
51
|
-
for (const name of (await readdir(outDir)).filter((n) => n.endsWith(".json"))) {
|
|
52
|
-
const file = join(outDir, name);
|
|
53
|
-
artifacts.push(file);
|
|
54
|
-
result.push(JSON.parse(await readFile(file, "utf8")));
|
|
55
|
-
}
|
|
56
|
-
// A dry run's stub packets are throwaway — remove them so a verification run does
|
|
57
|
-
// not litter content-org/inbox. Real runs keep their packets.
|
|
58
|
-
if (dry) {
|
|
59
|
-
await Promise.all(artifacts.map((f) => rm(f, { force: true })));
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return { dry, format, count, artifacts, result, log: stdout.trim() };
|
|
64
|
-
}
|
package/content-org/metrics.mjs
DELETED
|
@@ -1,166 +0,0 @@
|
|
|
1
|
-
// content-org — deterministic metrics module for the Growth/Analyst seat (#14).
|
|
2
|
-
// Parses a published-piece metrics file (CSV or JSON array), computes per-piece engagement
|
|
3
|
-
// rate, per-angle/format/platform rollups (joined to published/log.jsonl where slugs match),
|
|
4
|
-
// top/bottom performers, and a dataSufficiency verdict. Zero-dependency, no model calls —
|
|
5
|
-
// the growth seat gets this SUMMARY, never raw rows. Tolerant of missing columns: computes
|
|
6
|
-
// what it can and lists what it couldn't in `couldNotCompute`.
|
|
7
|
-
|
|
8
|
-
// Sufficiency boundaries: none = 0 rows; thin = < 10 pieces OR < 14-day span (or unusable dates).
|
|
9
|
-
export const THIN_MIN_PIECES = 10;
|
|
10
|
-
export const THIN_MIN_SPAN_DAYS = 14;
|
|
11
|
-
|
|
12
|
-
// Column aliases — normalize whatever header the platform export used (compared with
|
|
13
|
-
// non-alphanumerics stripped, lowercase: "Piece ID" / "piece_id" / "pieceId" all match).
|
|
14
|
-
const ALIASES = {
|
|
15
|
-
id: ["id", "piece", "pieceid", "slug", "postid"],
|
|
16
|
-
title: ["title", "name"],
|
|
17
|
-
platform: ["platform", "channel", "network"],
|
|
18
|
-
date: ["date", "published", "publishedat", "postdate", "publishdate"],
|
|
19
|
-
angle: ["angle"],
|
|
20
|
-
format: ["format"],
|
|
21
|
-
views: ["views", "plays", "videoviews", "impressions"],
|
|
22
|
-
reach: ["reach", "accountsreached"],
|
|
23
|
-
likes: ["likes", "reactions"],
|
|
24
|
-
comments: ["comments"],
|
|
25
|
-
shares: ["shares", "reposts"],
|
|
26
|
-
saves: ["saves", "bookmarks"],
|
|
27
|
-
watchTime: ["watchtime", "avgwatchtime", "watchtimesec"],
|
|
28
|
-
};
|
|
29
|
-
const NUMERIC = new Set(["views", "reach", "likes", "comments", "shares", "saves", "watchTime"]);
|
|
30
|
-
const INTERACTIONS = ["likes", "comments", "shares", "saves"];
|
|
31
|
-
|
|
32
|
-
function canonical(header) {
|
|
33
|
-
const h = String(header).trim().toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
34
|
-
for (const [field, aliases] of Object.entries(ALIASES)) if (aliases.includes(h)) return field;
|
|
35
|
-
return null; // unknown column — ignored
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
function coerce(rec) {
|
|
39
|
-
for (const k of Object.keys(rec)) {
|
|
40
|
-
if (NUMERIC.has(k)) {
|
|
41
|
-
const n = Number(String(rec[k]).replace(/,/g, ""));
|
|
42
|
-
if (Number.isNaN(n)) delete rec[k]; else rec[k] = n;
|
|
43
|
-
} else rec[k] = String(rec[k]);
|
|
44
|
-
}
|
|
45
|
-
return rec;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
function normalizeRecord(raw) {
|
|
49
|
-
const rec = {};
|
|
50
|
-
for (const [k, v] of Object.entries(raw)) {
|
|
51
|
-
const field = canonical(k);
|
|
52
|
-
if (field && v !== null && v !== undefined && v !== "") rec[field] = v;
|
|
53
|
-
}
|
|
54
|
-
return coerce(rec);
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// Minimal CSV line split with quoted-field support ("a, b" stays one cell, "" escapes a quote).
|
|
58
|
-
function splitCsvLine(line) {
|
|
59
|
-
const cells = []; let cur = "", inQ = false;
|
|
60
|
-
for (let i = 0; i < line.length; i++) {
|
|
61
|
-
const c = line[i];
|
|
62
|
-
if (inQ) {
|
|
63
|
-
if (c === '"') { if (line[i + 1] === '"') { cur += '"'; i++; } else inQ = false; }
|
|
64
|
-
else cur += c;
|
|
65
|
-
} else if (c === '"') inQ = true;
|
|
66
|
-
else if (c === ",") { cells.push(cur); cur = ""; }
|
|
67
|
-
else cur += c;
|
|
68
|
-
}
|
|
69
|
-
cells.push(cur);
|
|
70
|
-
return cells.map((s) => s.trim());
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const fieldsOf = (rows) => [...new Set(rows.flatMap((r) => Object.keys(r)))];
|
|
74
|
-
const round = (n) => (n === null || n === undefined ? null : Math.round(n * 10000) / 10000);
|
|
75
|
-
|
|
76
|
-
// --- parse: CSV or JSON array -> normalized rows ---------------------------------------
|
|
77
|
-
export function parseMetrics(text) {
|
|
78
|
-
const trimmed = String(text).trim();
|
|
79
|
-
if (!trimmed) return { rows: [], fields: [] };
|
|
80
|
-
if (trimmed.startsWith("[")) {
|
|
81
|
-
const rows = JSON.parse(trimmed).map(normalizeRecord);
|
|
82
|
-
return { rows, fields: fieldsOf(rows) };
|
|
83
|
-
}
|
|
84
|
-
const lines = trimmed.split(/\r?\n/).filter((l) => l.trim() !== "");
|
|
85
|
-
const header = splitCsvLine(lines[0]).map(canonical);
|
|
86
|
-
const rows = lines.slice(1).map((line) => {
|
|
87
|
-
const cells = splitCsvLine(line);
|
|
88
|
-
const rec = {};
|
|
89
|
-
header.forEach((field, i) => { if (field && cells[i] !== undefined && cells[i] !== "") rec[field] = cells[i]; });
|
|
90
|
-
return coerce(rec);
|
|
91
|
-
});
|
|
92
|
-
return { rows, fields: fieldsOf(rows) };
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
// --- engagement rate: interactions / (views, else reach); null when it can't be computed
|
|
96
|
-
export function engagementRate(row) {
|
|
97
|
-
const denom = typeof row.views === "number" ? row.views : row.reach;
|
|
98
|
-
if (typeof denom !== "number" || denom <= 0) return null;
|
|
99
|
-
const present = INTERACTIONS.filter((k) => typeof row[k] === "number");
|
|
100
|
-
if (!present.length) return null;
|
|
101
|
-
return present.reduce((s, k) => s + row[k], 0) / denom;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// --- rollup: group rows by a field (angle/format/platform), avg engagement, sorted best-first
|
|
105
|
-
export function rollup(rows, key) {
|
|
106
|
-
const groups = new Map();
|
|
107
|
-
for (const r of rows) {
|
|
108
|
-
if (!r[key]) continue;
|
|
109
|
-
const g = groups.get(r[key]) || { [key]: r[key], pieces: 0, views: 0, ers: [] };
|
|
110
|
-
g.pieces++;
|
|
111
|
-
if (typeof r.views === "number") g.views += r.views;
|
|
112
|
-
const er = engagementRate(r);
|
|
113
|
-
if (er !== null) g.ers.push(er);
|
|
114
|
-
groups.set(r[key], g);
|
|
115
|
-
}
|
|
116
|
-
return [...groups.values()]
|
|
117
|
-
.map(({ ers, ...g }) => ({ ...g, avgEngagementRate: ers.length ? round(ers.reduce((a, b) => a + b, 0) / ers.length) : null }))
|
|
118
|
-
.sort((a, b) => (b.avgEngagementRate ?? -1) - (a.avgEngagementRate ?? -1));
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
// --- dataSufficiency: "none" | "thin" | "adequate" + plain-language note ----------------
|
|
122
|
-
export function sufficiency(rows) {
|
|
123
|
-
if (!rows.length) {
|
|
124
|
-
return { verdict: "none", note: `0 metric rows — nothing to read yet. No trend, no recommendation. Revisit once ~${THIN_MIN_PIECES} pieces have published over ${THIN_MIN_SPAN_DAYS}+ days.` };
|
|
125
|
-
}
|
|
126
|
-
const dates = rows.map((r) => Date.parse(r.date)).filter((t) => !Number.isNaN(t));
|
|
127
|
-
const spanDays = dates.length >= 2 ? Math.round((Math.max(...dates) - Math.min(...dates)) / 86400000) : null;
|
|
128
|
-
const reasons = [];
|
|
129
|
-
if (rows.length < THIN_MIN_PIECES) reasons.push(`only ${rows.length} piece(s) (want ${THIN_MIN_PIECES}+)`);
|
|
130
|
-
if (spanDays === null) reasons.push("no usable dates, so the time span is unknown");
|
|
131
|
-
else if (spanDays < THIN_MIN_SPAN_DAYS) reasons.push(`only a ${spanDays}-day span (want ${THIN_MIN_SPAN_DAYS}+)`);
|
|
132
|
-
if (reasons.length) {
|
|
133
|
-
return { verdict: "thin", note: `Data is thin: ${reasons.join("; ")}. Read directionally only — one strong piece is not a strategy; do not restack the board on this.` };
|
|
134
|
-
}
|
|
135
|
-
return { verdict: "adequate", note: `${rows.length} pieces over ${spanDays} days — enough to read angles and formats with normal care.` };
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// --- summarize: the one entry point the orchestrator calls ------------------------------
|
|
139
|
-
// input: raw file text (CSV/JSON) or pre-parsed rows. published: entries from published/log.jsonl,
|
|
140
|
-
// joined by slug to fill format/client/tier on rows that lack them.
|
|
141
|
-
export function summarize(input, { published = [] } = {}) {
|
|
142
|
-
const { rows, fields } = typeof input === "string" ? parseMetrics(input) : { rows: input, fields: fieldsOf(input) };
|
|
143
|
-
const bySlug = new Map(published.filter((p) => p.slug).map((p) => [String(p.slug).toLowerCase(), p]));
|
|
144
|
-
for (const r of rows) {
|
|
145
|
-
const p = r.id && bySlug.get(String(r.id).toLowerCase());
|
|
146
|
-
if (p) for (const k of ["format", "client", "tier"]) if (r[k] === undefined && p[k] != null) r[k] = p[k];
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
const couldNotCompute = [];
|
|
150
|
-
const pieces = rows.map((r) => ({ ...r, engagementRate: round(engagementRate(r)) }));
|
|
151
|
-
const noEr = pieces.filter((p) => p.engagementRate === null).length;
|
|
152
|
-
if (noEr) couldNotCompute.push(`engagementRate for ${noEr}/${pieces.length} piece(s) — missing views/reach or all interaction columns`);
|
|
153
|
-
|
|
154
|
-
const rollups = {};
|
|
155
|
-
for (const [name, key] of [["byAngle", "angle"], ["byFormat", "format"], ["byPlatform", "platform"]]) {
|
|
156
|
-
const g = rollup(rows, key);
|
|
157
|
-
if (g.length) rollups[name] = g;
|
|
158
|
-
else couldNotCompute.push(`${name} rollup — no '${key}' on any row (and no published/log.jsonl join filled it)`);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
const ranked = pieces.filter((p) => p.engagementRate !== null).sort((a, b) => b.engagementRate - a.engagementRate);
|
|
162
|
-
const top = ranked.slice(0, 3);
|
|
163
|
-
const bottom = ranked.length > 3 ? ranked.slice(-3).reverse() : []; // worst first; empty when it would just repeat top
|
|
164
|
-
|
|
165
|
-
return { dataSufficiency: sufficiency(rows), pieces, rollups, top, bottom, fieldsPresent: fields, couldNotCompute };
|
|
166
|
-
}
|
|
@@ -1,246 +0,0 @@
|
|
|
1
|
-
// content-org — local orchestrator (the "prep half": API-only text seats).
|
|
2
|
-
// Showrunner → Strategy → per-piece [Ideator → Researcher → Script → Editor → Feed → QC]
|
|
3
|
-
// Output: ready-to-assemble packet per piece in inbox/<date>/<slug>.json → Kayla reviews,
|
|
4
|
-
// generates clips into clips/<slug>/, then producer.mjs assembles.
|
|
5
|
-
//
|
|
6
|
-
// Run (full pipeline — "together"):
|
|
7
|
-
// node orchestrator.mjs <format> <count> [--client=acme] [--tier=growth] [--dry-run]
|
|
8
|
-
// e.g. node orchestrator.mjs scam-reveal 2 --client=acme --tier=growth
|
|
9
|
-
// --dry-run uses canned data (NO API key needed) to verify the pipeline end-to-end.
|
|
10
|
-
//
|
|
11
|
-
// Run (single seat — "separately"):
|
|
12
|
-
// node orchestrator.mjs --seat <key> --input <file-or-json-or-text> [--out <file>] [--dry-run]
|
|
13
|
-
// e.g. node orchestrator.mjs --seat ideator --input briefs/angle.json
|
|
14
|
-
// node orchestrator.mjs --seat qc --input '{"script":"..."}' --dry-run
|
|
15
|
-
// node orchestrator.mjs --seat growth --input examples/metrics-sample.csv --dry-run
|
|
16
|
-
// Seat keys: showrunner, strategy, ideator, research, script, editor, cut, feed, qc, growth.
|
|
17
|
-
// (growth runs metrics.mjs deterministically FIRST; the model sees the computed summary, never raw rows)
|
|
18
|
-
// Output: the seat's schema-shaped JSON to stdout (or --out <file>).
|
|
19
|
-
//
|
|
20
|
-
// Model tiers come from config.mjs (Haiku routine / Sonnet script+editor). Producer
|
|
21
|
-
// (clip-gen + voice + FFmpeg) is a separate step — see producer.mjs.
|
|
22
|
-
|
|
23
|
-
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
24
|
-
import { join } from "node:path";
|
|
25
|
-
import { pathToFileURL } from "node:url";
|
|
26
|
-
import { MODELS, SEAT_MODEL, RISK } from "./config.mjs";
|
|
27
|
-
import { summarize } from "./metrics.mjs";
|
|
28
|
-
|
|
29
|
-
const argv = process.argv.slice(2);
|
|
30
|
-
// Flags: --k=v always works; for value-taking flags, `--k v` (space-separated) also works.
|
|
31
|
-
const VALUE_FLAGS = new Set(["seat", "input", "out", "client", "tier"]);
|
|
32
|
-
const flags = {};
|
|
33
|
-
const positional = [];
|
|
34
|
-
for (let i = 0; i < argv.length; i++) {
|
|
35
|
-
const a = argv[i];
|
|
36
|
-
if (!a.startsWith("--")) { positional.push(a); continue; }
|
|
37
|
-
const [k, v] = a.replace(/^--/, "").split(/=(.*)/s);
|
|
38
|
-
if (v !== undefined) flags[k] = v;
|
|
39
|
-
else if (VALUE_FLAGS.has(k) && argv[i + 1] !== undefined && !argv[i + 1].startsWith("--")) flags[k] = argv[++i];
|
|
40
|
-
else flags[k] = true;
|
|
41
|
-
}
|
|
42
|
-
const format = positional[0];
|
|
43
|
-
const count = Number(positional[1] || "1");
|
|
44
|
-
const DRY = !!flags["dry-run"];
|
|
45
|
-
|
|
46
|
-
// SDK is loaded lazily so --dry-run needs no dependencies installed.
|
|
47
|
-
let _client = null;
|
|
48
|
-
async function getClient() {
|
|
49
|
-
if (!_client) {
|
|
50
|
-
const { default: Anthropic } = await import("@anthropic-ai/sdk"); // reads ANTHROPIC_API_KEY or `ant auth login`
|
|
51
|
-
_client = new Anthropic();
|
|
52
|
-
}
|
|
53
|
-
return _client;
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
// --- Seats: role prompt + output JSON schema (model chosen per-seat from config) ------
|
|
57
|
-
const AUDIENCE =
|
|
58
|
-
"Primary audience: older Facebook users. Faceless, disclosed-AI-where-Kayla-chooses. " +
|
|
59
|
-
"Brand voice: 'declare what's real'. NEVER moralize disclosure — it is a per-piece choice. " +
|
|
60
|
-
"The one hard line on scam content: it is THEATRE (instant reveal, captures nothing, no real " +
|
|
61
|
-
"non-consenting person is harmed).";
|
|
62
|
-
|
|
63
|
-
const obj = (properties, required) => ({ type: "object", additionalProperties: false, properties, required });
|
|
64
|
-
const str = { type: "string" };
|
|
65
|
-
const bool = { type: "boolean" };
|
|
66
|
-
const arr = (items) => ({ type: "array", items });
|
|
67
|
-
|
|
68
|
-
const SHOWRUNNER = {
|
|
69
|
-
key: "showrunner", title: "Showrunner",
|
|
70
|
-
system: `You are the Showrunner — voice + continuity across every piece. ${AUDIENCE}`,
|
|
71
|
-
schema: obj({ voiceGuardrails: str, continuityNotes: str }, ["voiceGuardrails", "continuityNotes"]),
|
|
72
|
-
};
|
|
73
|
-
const STRATEGY = {
|
|
74
|
-
key: "strategy", title: "Content Strategy",
|
|
75
|
-
system: `You are Content Strategy. Given a format and count, produce distinct, non-repetitive angles that land with the audience. ${AUDIENCE}`,
|
|
76
|
-
schema: obj({ pieces: arr(obj({ slug: str, angle: str, why: str }, ["slug", "angle", "why"])) }, ["pieces"]),
|
|
77
|
-
};
|
|
78
|
-
const PIPELINE = [
|
|
79
|
-
{ key: "ideator", title: "Ideator",
|
|
80
|
-
system: `You are the Ideator. Turn the angle into a concept + a 3-second scroll-stopping hook + beat list. ${AUDIENCE}`,
|
|
81
|
-
schema: obj({ hook: str, concept: str, beats: arr(str) }, ["hook", "concept", "beats"]) },
|
|
82
|
-
{ key: "research", title: "Researcher",
|
|
83
|
-
system: `You are the Researcher. Source the facts/material; flag any claim that must be verified before publish. ${AUDIENCE}`,
|
|
84
|
-
schema: obj({ facts: arr(str), claimsToVerify: arr(str) }, ["facts", "claimsToVerify"]) },
|
|
85
|
-
{ key: "script", title: "Script Agent",
|
|
86
|
-
system: `You are the Script Agent. Write the full voiceover script (tight, spoken-word, older-FB cadence) + per-beat on-screen text. ${AUDIENCE}`,
|
|
87
|
-
schema: obj({ script: str, onScreenText: arr(str), voNotes: str }, ["script", "onScreenText", "voNotes"]) },
|
|
88
|
-
{ key: "editor", title: "Script Editor",
|
|
89
|
-
system: `You are the Script Editor. Tighten the script, enforce the Showrunner's voice, fact-check against research. Return the improved script and what you changed. ${AUDIENCE}`,
|
|
90
|
-
schema: obj({ script: str, changes: arr(str), voicePass: bool }, ["script", "changes", "voicePass"]) },
|
|
91
|
-
{ key: "cut", title: "Video Editor",
|
|
92
|
-
system: `You are the Video Editor. Turn the script into a concrete shot list Kayla can generate clips from: one entry per clip (5-10s each, ~4-7 clips for a ~30-45s Reel), each with a generation prompt (subject, era/setting, camera, mood — written for an AI video model), plus pacing notes and where each on-screen text lands. Number clips 01, 02… ${AUDIENCE}`,
|
|
93
|
-
schema: obj({
|
|
94
|
-
shots: arr(obj({ file: str, seconds: { type: "number" }, prompt: str, onScreenText: str }, ["file", "seconds", "prompt", "onScreenText"])),
|
|
95
|
-
pacingNotes: str,
|
|
96
|
-
}, ["shots", "pacingNotes"]) },
|
|
97
|
-
{ key: "feed", title: "Feed Optimizer",
|
|
98
|
-
system: `You are the Feed Optimizer. Nail the first 3 seconds; write the caption + hashtags + per-platform notes (Facebook, Instagram Reels). ${AUDIENCE}`,
|
|
99
|
-
schema: obj({ firstThreeSec: str, caption: str, hashtags: arr(str),
|
|
100
|
-
perPlatform: obj({ facebook: str, instagram: str }, ["facebook", "instagram"]) },
|
|
101
|
-
["firstThreeSec", "caption", "hashtags", "perPlatform"]) },
|
|
102
|
-
{ key: "qc", title: "Disclosure/QC",
|
|
103
|
-
system: `You are Disclosure/QC — the publish gate. Check: (1) every claim accurate/supported; (2) if scam-reveal or is-this-real, the THEATRE line holds (instant reveal, captures nothing, no real non-consenting person harmed); (3) recommend Kayla's per-piece disclosure call (labeled / reveal-is-the-point / neither) but DO NOT enforce always-labeling. List blockers if any check fails. ${AUDIENCE}`,
|
|
104
|
-
schema: obj({ claimsClean: bool, theatreLineOk: bool, disclosureCall: str, blockers: arr(str) },
|
|
105
|
-
["claimsClean", "theatreLineOk", "disclosureCall", "blockers"]) },
|
|
106
|
-
];
|
|
107
|
-
// Growth/Analyst (#14) — NOT in the per-piece pipeline; closes the loop after publish.
|
|
108
|
-
// Runs only via --seat growth. metrics.mjs computes the numbers deterministically first;
|
|
109
|
-
// the model reads that summary (never raw rows) and honors its dataSufficiency verdict.
|
|
110
|
-
const GROWTH = {
|
|
111
|
-
key: "growth", title: "Growth/Analyst",
|
|
112
|
-
system: `You are the Growth/Analyst. You receive a DETERMINISTIC metrics summary computed by metrics.mjs — trust its numbers, do not recompute them. Read what landed and why: attribute movement to a specific angle, hook, format, or timing, never vanity totals. One viral piece is not a strategy. Every finding must end in a concrete next-brief recommendation for Content Strategy. Copy the summary's dataSufficiency verdict/note through unchanged; if it is "none" or "thin", say so plainly, scale confidence down, and do NOT fabricate a trend. ${AUDIENCE}`,
|
|
113
|
-
schema: obj({
|
|
114
|
-
dataSufficiency: obj({ verdict: str, note: str }, ["verdict", "note"]), // verdict: "none" | "thin" | "adequate"
|
|
115
|
-
pieceReads: arr(obj({ id: str, read: str }, ["id", "read"])), // per-piece: what landed / didn't, and why
|
|
116
|
-
angleRollup: arr(obj({ angle: str, read: str }, ["angle", "read"])), // per-angle rollup read
|
|
117
|
-
recommendations: arr(str), // next-brief signal for Content Strategy
|
|
118
|
-
}, ["dataSufficiency", "pieceReads", "angleRollup", "recommendations"]),
|
|
119
|
-
};
|
|
120
|
-
|
|
121
|
-
// All 10 prompt seats, addressable by key (for --seat single-invocation and tests).
|
|
122
|
-
export const SEATS = Object.fromEntries(
|
|
123
|
-
[SHOWRUNNER, STRATEGY, ...PIPELINE, GROWTH].map((s) => [s.key, s])
|
|
124
|
-
);
|
|
125
|
-
|
|
126
|
-
// --- dry-run: fabricate schema-shaped stub data, no API call ---------------------------
|
|
127
|
-
export function stub(schema, seatKey) {
|
|
128
|
-
if (schema.type === "string") return `[${seatKey}:sample]`;
|
|
129
|
-
if (schema.type === "boolean") return true;
|
|
130
|
-
if (schema.type === "number") return 5; // e.g. cut.shots[].seconds
|
|
131
|
-
if (schema.type === "array") return [stub(schema.items, seatKey), stub(schema.items, seatKey)];
|
|
132
|
-
if (schema.type === "object") {
|
|
133
|
-
const o = {};
|
|
134
|
-
for (const [k, v] of Object.entries(schema.properties)) o[k] = stub(v, `${seatKey}.${k}`);
|
|
135
|
-
return o;
|
|
136
|
-
}
|
|
137
|
-
return null;
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
async function runSeat(seat, userContent) {
|
|
141
|
-
if (DRY) return stub(seat.schema, seat.key);
|
|
142
|
-
const model = MODELS[SEAT_MODEL[seat.key]] || MODELS.sonnet;
|
|
143
|
-
const client = await getClient();
|
|
144
|
-
const res = await client.messages.create({
|
|
145
|
-
model, max_tokens: 16000, system: seat.system,
|
|
146
|
-
output_config: { format: { type: "json_schema", schema: seat.schema } },
|
|
147
|
-
messages: [{ role: "user", content: userContent }],
|
|
148
|
-
});
|
|
149
|
-
const text = res.content.find((b) => b.type === "text")?.text ?? "{}";
|
|
150
|
-
return JSON.parse(text);
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
// Growth input prep: parse the metrics file text and join published/log.jsonl (if any) for context.
|
|
154
|
-
function growthSummary(raw) {
|
|
155
|
-
const logPath = join("published", "log.jsonl");
|
|
156
|
-
const published = existsSync(logPath)
|
|
157
|
-
? readFileSync(logPath, "utf8").trim().split("\n").filter(Boolean).map((l) => JSON.parse(l))
|
|
158
|
-
: [];
|
|
159
|
-
return summarize(raw, { published });
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
// --- single-seat mode ("separately"): run one seat alone, JSON in -> JSON out ----------
|
|
163
|
-
async function runSingleSeat() {
|
|
164
|
-
const key = flags.seat;
|
|
165
|
-
const seat = SEATS[key];
|
|
166
|
-
if (!seat) {
|
|
167
|
-
console.error(`Unknown seat '${key}'. Seats: ${Object.keys(SEATS).join(", ")}`);
|
|
168
|
-
process.exit(1);
|
|
169
|
-
}
|
|
170
|
-
if (!flags.input || flags.input === true) {
|
|
171
|
-
console.error(`--seat ${key} needs --input <file-or-json-or-text>`);
|
|
172
|
-
process.exit(1);
|
|
173
|
-
}
|
|
174
|
-
// Input: a file path (read it), or an inline JSON/text string (use as-is).
|
|
175
|
-
let userContent = String(flags.input);
|
|
176
|
-
if (existsSync(userContent)) userContent = readFileSync(userContent, "utf8");
|
|
177
|
-
let out;
|
|
178
|
-
if (key === "growth") {
|
|
179
|
-
// Deterministic metrics module runs FIRST; the model gets the computed summary, never raw rows.
|
|
180
|
-
const summary = growthSummary(userContent);
|
|
181
|
-
console.error(`[metrics.mjs] ${summary.pieces.length} piece(s), sufficiency=${summary.dataSufficiency.verdict}`);
|
|
182
|
-
if (!DRY && summary.dataSufficiency.verdict === "none") {
|
|
183
|
-
// 0 rows: the honest read is deterministic — don't spend a model call restating it.
|
|
184
|
-
out = { dataSufficiency: summary.dataSufficiency, pieceReads: [], angleRollup: [], recommendations: [] };
|
|
185
|
-
} else {
|
|
186
|
-
out = await runSeat(seat, `Deterministic metrics summary (computed by metrics.mjs; trust these numbers):\n${JSON.stringify(summary, null, 2)}`);
|
|
187
|
-
}
|
|
188
|
-
} else {
|
|
189
|
-
out = await runSeat(seat, userContent);
|
|
190
|
-
}
|
|
191
|
-
const json = JSON.stringify(out, null, 2);
|
|
192
|
-
if (flags.out && flags.out !== true) {
|
|
193
|
-
writeFileSync(flags.out, json);
|
|
194
|
-
console.error(`${DRY ? "[DRY-RUN] " : ""}[${seat.title}] ${SEAT_MODEL[key]} -> ${flags.out}`);
|
|
195
|
-
} else {
|
|
196
|
-
if (DRY) console.error(`[DRY-RUN] [${seat.title}] ${SEAT_MODEL[key]}`);
|
|
197
|
-
console.log(json);
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
|
|
201
|
-
async function main() {
|
|
202
|
-
if (flags.seat) return runSingleSeat();
|
|
203
|
-
if (!format || !RISK[format]) {
|
|
204
|
-
console.error(`Usage: node orchestrator.mjs <format> <count> [--client=x] [--tier=y] [--dry-run]\n formats: ${Object.keys(RISK).join(", ")}`);
|
|
205
|
-
process.exit(1);
|
|
206
|
-
}
|
|
207
|
-
const date = new Date().toISOString().slice(0, 10);
|
|
208
|
-
const outDir = join("inbox", date);
|
|
209
|
-
mkdirSync(outDir, { recursive: true });
|
|
210
|
-
console.log(`${DRY ? "[DRY-RUN] " : ""}format=${format} count=${count} client=${flags.client || "-"} tier=${flags.tier || "-"}`);
|
|
211
|
-
|
|
212
|
-
const showrunner = await runSeat(SHOWRUNNER, `Format: ${format}. Set voice + continuity guardrails.`);
|
|
213
|
-
console.log(`[Showrunner] ${SEAT_MODEL.showrunner} ok`);
|
|
214
|
-
const board = await runSeat(STRATEGY, `Format: ${format}. Count: ${count}. Voice: ${showrunner.voiceGuardrails}. Produce ${count} distinct angles.`);
|
|
215
|
-
console.log(`[Strategy] ${SEAT_MODEL.strategy} ${board.pieces.length} angles`);
|
|
216
|
-
|
|
217
|
-
for (let i = 0; i < Math.min(count, board.pieces.length); i++) {
|
|
218
|
-
const piece = board.pieces[i];
|
|
219
|
-
const slug = DRY ? `dryrun-${i + 1}` : piece.slug;
|
|
220
|
-
console.log(`\n=== ${slug} — ${piece.angle} ===`);
|
|
221
|
-
const packet = {
|
|
222
|
-
slug, format, client: flags.client || null, tier: flags.tier || null,
|
|
223
|
-
angle: piece.angle, why: piece.why,
|
|
224
|
-
voiceGuardrails: showrunner.voiceGuardrails, continuityNotes: showrunner.continuityNotes,
|
|
225
|
-
createdAt: date,
|
|
226
|
-
};
|
|
227
|
-
let context = `Format: ${format}. Angle: ${piece.angle}. Why: ${piece.why}. Voice: ${showrunner.voiceGuardrails}.`;
|
|
228
|
-
for (const seat of PIPELINE) {
|
|
229
|
-
const out = await runSeat(seat, `${context}\n\nWork so far: ${JSON.stringify(packet)}`);
|
|
230
|
-
packet[seat.key] = out;
|
|
231
|
-
console.log(` [${seat.title}] ${SEAT_MODEL[seat.key]} ok`);
|
|
232
|
-
if (out.script) context += `\nScript: ${out.script}`;
|
|
233
|
-
}
|
|
234
|
-
const held = RISK[format] === "hold" || (packet.qc?.blockers?.length ?? 0) > 0;
|
|
235
|
-
packet.approval = held ? "hold" : "auto";
|
|
236
|
-
const file = join(outDir, `${slug}.json`);
|
|
237
|
-
writeFileSync(file, JSON.stringify(packet, null, 2));
|
|
238
|
-
console.log(` -> ${file} [${packet.approval.toUpperCase()}]`);
|
|
239
|
-
}
|
|
240
|
-
console.log(`\nDone. Packets in ${outDir}/. Next: review → generate clips → producer.mjs assembles.`);
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// Only auto-run when executed directly (lets tests `import { SEATS }` without side effects).
|
|
244
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
|
245
|
-
main().catch((e) => { console.error(e); process.exit(1); });
|
|
246
|
-
}
|
package/content-org/package.json
DELETED
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "content-org",
|
|
3
|
-
"version": "0.1.0",
|
|
4
|
-
"private": true,
|
|
5
|
-
"type": "module",
|
|
6
|
-
"description": "AI content production org — local MVP orchestrator (text pipeline)",
|
|
7
|
-
"scripts": {
|
|
8
|
-
"produce": "node orchestrator.mjs",
|
|
9
|
-
"test": "node test.mjs"
|
|
10
|
-
},
|
|
11
|
-
"dependencies": {
|
|
12
|
-
"@anthropic-ai/sdk": "^0.68.0"
|
|
13
|
-
}
|
|
14
|
-
}
|