#!/usr/bin/env node /** * @agent-manifest * name: research * title: Editorial Calendar Researcher * class: content * trigger: manual (auto-invoked by writer when the evergreen backlog runs low) * description: Top up the evergreen backlog in calendar.json (additive, deduped); timely news is the news radar's job. * model: research=claude-opus-4-8 * prompt: prompts/research.system.md * skills: - * tools: lib/claude.mjs, lib/calendar.mjs * reads: - * writes: - * created: 2026-07-04 * @end */ // Editorial research: top up the EVERGREEN backlog in calendar.json (additive, deduped). // Timely news is handled separately by the news radar. Usage: // node agents/scripts/research.mjs [count] import { readdirSync, existsSync } from "node:fs"; import { dirname, resolve, join } from "node:path"; import { fileURLToPath } from "node:url"; import { runClaude } from "./lib/claude.mjs"; import { loadJson, todayInTz, addDays } from "./lib/util.mjs"; import { readCalendar, mergeEntries } from "./lib/calendar.mjs"; const HERE = dirname(fileURLToPath(import.meta.url)); const PIPELINE = resolve(HERE, ".."); const cfg = loadJson(join(PIPELINE, "config.json")); const root = cfg.paths.projectRoot; const publishedSlugs = () => { const d = join(root, cfg.paths.blogContentDir); return existsSync(d) ? readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).map((x) => x.name) : []; }; /** Scan for evergreen topics and additively merge them into calendar.json. Returns count added. */ export async function runResearch({ count } = {}) { const n = count || cfg.editorial.calendarDays; const today = todayInTz(cfg.editorial.timezone); const calendarPath = join(root, cfg.paths.calendar); const scanPath = join(root, "agents", "research-scan.json"); // Per-type target counts from the configured mix. const types = Object.keys(cfg.editorial.contentMix); const counts = {}; let assigned = 0; for (const t of types) { counts[t] = Math.round(cfg.editorial.contentMix[t] * n); assigned += counts[t]; } const biggest = types.reduce((a, b) => (counts[a] >= counts[b] ? a : b)); counts[biggest] += n - assigned; const exclude = [...new Set([...readCalendar(calendarPath).map((e) => e.slug), ...publishedSlugs()])]; const prompt = `Propose ${n} evergreen Comiida topics. - Write a JSON array of ${n} topic proposals to: ${scanPath} - Do NOT assign dates (the pipeline schedules them). - Allowed content types: ${types.join(", ")} - Rough target counts per type: ${JSON.stringify(counts)} - DO NOT reuse any of these slugs: ${exclude.join(", ") || "(none yet)"} - Read existing posts if useful: ${join(root, cfg.paths.blogContentDir)} Follow the output contract in your system prompt exactly.`; console.log(`[research] proposing ${n} evergreen topics, mix ${JSON.stringify(counts)}`); await runClaude({ prompt, systemPromptFile: join(PIPELINE, "prompts/research.system.md"), model: cfg.models.research, allowedTools: "Read Glob Grep WebSearch Write", addDirs: [root], cwd: root, logFile: join(root, cfg.paths.logsDir, `research-${today}.log`), }); const proposals = existsSync(scanPath) ? loadJson(scanPath) : []; if (!proposals.length) { console.log("[research] no proposals produced."); return 0; } // Schedule new topics one per day, starting the day after the latest existing date. const dates = readCalendar(calendarPath).map((e) => (e.date || "").slice(0, 10)).filter(Boolean); const maxDate = dates.sort().pop(); let next = maxDate && maxDate >= today ? addDays(maxDate, 1) : today; const entries = proposals.map((p) => { const entry = { ...p, date: next, status: "planned" }; next = addDays(next, 1); return entry; }); const added = await mergeEntries(calendarPath, entries); const planned = readCalendar(calendarPath).filter((e) => e.status === "planned").length; console.log(`[research] added ${added} evergreen topics. Planned backlog now: ${planned}.`); return added; } // CLI if (import.meta.url === `file://${process.argv[1]}`) { const count = parseInt(process.argv[2], 10) || undefined; await runResearch({ count }); }