content_supervisor.js
// Overnight supervisor: runs the full 3-round content pipeline end to end.
// Round 1 (draft, web-search) -> await -> Round 2 (SEO rewrite using seo/ intel)
// -> await -> Round 3 (editorial polish) -> await -> final/ + index.md
//
// Each round submits ONE batch, polls until it ends, writes its outputs, then the
// next round begins. Safe to leave running unattended.
//
// Usage:
// node content_supervisor.js # all 15, all 3 rounds
// node content_supervisor.js --resume # skip items whose output already exists
// node content_supervisor.js --limit 2 # first N briefs (cheap smoke test)
// node content_supervisor.js --slug <slug> # a single brief through all 3 rounds
//
// Notes:
// - Model is probed once (Fable 5 -> Opus 4.8 fallback) and reused for all rounds.
// - A per-item failure in a round just drops that item from later rounds; the rest
// flow through. A round only starts for items that have the prior round's output.
// - Resumable: with --resume, a crashed/killed run picks up where it left off.
const fs = require("fs");
const path = require("path");
const { parseBriefs } = require("./lib/briefs");
const { probeModel, runRound, WEB_SEARCH } = require("./lib/batch");
const { cleanArticle } = require("./lib/clean");
const P = require("./lib/prompts");
const DIRS = {
round1: path.join(__dirname, "round1"),
round2: path.join(__dirname, "round2"),
round3: path.join(__dirname, "final"),
seo: path.join(__dirname, "seo"),
};
const LOG_FILE = path.join(__dirname, "supervisor.log");
const STATUS_FILE = path.join(__dirname, "supervisor_status.json");
function log(msg) {
const line = `[${new Date().toISOString()}] ${msg}`;
console.log(line);
fs.appendFileSync(LOG_FILE, line + "\n");
}
function arg(name) {
const i = process.argv.indexOf(`--${name}`);
return i >= 0 ? process.argv[i + 1] : null;
}
const hasFlag = (name) => process.argv.includes(`--${name}`);
function readMaybe(p) {
try {
return fs.readFileSync(p, "utf8");
} catch {
return null;
}
}
function readJson(p) {
const t = readMaybe(p);
return t ? JSON.parse(t) : null;
}
const status = { started_at: new Date().toISOString(), model: null, rounds: {} };
function saveStatus() {
fs.writeFileSync(STATUS_FILE, JSON.stringify(status, null, 2));
}
// Run one round over `items`; write each cleaned result via writeFn(slug, text).
async function doRound(roundName, model, items, buildFn, writeFn) {
log(`--- ${roundName}: ${items.length} item(s) ---`);
if (!items.length) {
status.rounds[roundName] = { ok: 0, failed: 0, skipped: 0, items: [] };
saveStatus();
return [];
}
const results = await runRound(model, items, buildFn);
const ok = [];
const rec = [];
for (const b of items) {
const r = results.get(b.slug);
if (r && r.ok) {
writeFn(b.slug, cleanArticle(r.text));
log(` ✓ ${b.slug} (${r.model})`);
ok.push(b);
rec.push({ slug: b.slug, ok: true, model: r.model });
} else {
log(` ✗ ${b.slug} — ${r ? r.error || r.stop_reason : "no result"}`);
rec.push({ slug: b.slug, ok: false, reason: r ? r.error || r.stop_reason : "no result" });
}
}
status.rounds[roundName] = { ok: ok.length, failed: items.length - ok.length, items: rec };
saveStatus();
return ok; // items that succeeded flow to the next round
}
async function main() {
for (const d of Object.values(DIRS)) fs.mkdirSync(d, { recursive: true });
const resume = hasFlag("resume");
const { standingRules, briefs } = parseBriefs();
let all = briefs;
if (arg("slug")) all = briefs.filter((b) => b.slug === arg("slug"));
else if (arg("limit")) all = briefs.slice(0, Number(arg("limit")));
if (!all.length) {
log(`No briefs matched. Available: ${briefs.map((b) => b.slug).join(", ")}`);
return;
}
log(`=== SUPERVISOR START: ${all.length} brief(s), resume=${resume} ===`);
const model = await probeModel();
status.model = model;
saveStatus();
log(`Writing model: ${model}`);
const exists = (dir, slug) => fs.existsSync(path.join(dir, `${slug}.md`));
// ---- Round 1: draft (web search) ----
const sys1 = P.round1System(standingRules);
const r1items = resume ? all.filter((b) => !exists(DIRS.round1, b.slug)) : all;
if (resume && r1items.length < all.length)
log(`(resume) round1: skipping ${all.length - r1items.length} already-written`);
const r1ok = await doRound(
"round1_draft",
model,
r1items,
(b) => ({ system: sys1, userContent: P.round1User(b), max_tokens: 16000, tools: [WEB_SEARCH] }),
(slug, text) => fs.writeFileSync(path.join(DIRS.round1, `${slug}.md`), text),
);
// Items eligible for round 2 = anything with a round1 draft (this run or prior).
const haveR1 = all.filter((b) => exists(DIRS.round1, b.slug));
// ---- Round 2: SEO rewrite using Chicago-IL intel ----
const sys2 = P.round2System(standingRules);
const r2items = resume ? haveR1.filter((b) => !exists(DIRS.round2, b.slug)) : haveR1;
await doRound(
"round2_seo",
model,
r2items,
(b) => ({
system: sys2,
userContent: P.round2User(
b,
readMaybe(path.join(DIRS.round1, `${b.slug}.md`)),
readJson(path.join(DIRS.seo, b.slug, "intel.json")),
),
max_tokens: 16000,
}),
(slug, text) => fs.writeFileSync(path.join(DIRS.round2, `${slug}.md`), text),
);
const haveR2 = all.filter((b) => exists(DIRS.round2, b.slug));
// ---- Round 3: editorial polish -> final/ ----
const sys3 = P.round3System(standingRules);
const r3items = resume ? haveR2.filter((b) => !exists(DIRS.round3, b.slug)) : haveR2;
await doRound(
"round3_final",
model,
r3items,
(b) => ({ system: sys3, userContent: P.round3User(readMaybe(path.join(DIRS.round2, `${b.slug}.md`))), max_tokens: 16000 }),
(slug, text) => fs.writeFileSync(path.join(DIRS.round3, `${slug}.md`), text),
);
// ---- Final index ----
const finals = all.filter((b) => exists(DIRS.round3, b.slug));
const index = ["# Northern Illinois Recovery Tier A — Final Posts", ""];
for (const b of finals)
index.push(`- [${b.workingTitle}](${b.slug}.md) — \`${b.targetKeyword}\``);
fs.writeFileSync(path.join(DIRS.round3, "index.md"), index.join("\n") + "\n");
status.finished_at = new Date().toISOString();
saveStatus();
log(
`=== SUPERVISOR DONE: round1 ${r1ok.length}/${r1items.length} · final posts ${finals.length}/${all.length} → final/ ===`,
);
}
main().catch((e) => {
log(`FATAL: ${e.stack || e}`);
saveStatus();
process.exit(1);
});