seedproject-web/agents/scripts/write-daily.mjs
Carlos Arias 2c969c0753 feat: content-pipeline/ → agents/ — formalize the agent system in the seed
Adopt the agents/ architecture proven on medellin.co (reference impl):

- Move the content engine to a top-level agents/ dir: orchestrators, prompts,
  config, run.sh, admin console, shared libs. All content-pipeline literals
  repointed (config paths, scripts, admin, LLM-facing prompts/image.md string,
  configure.mjs, new-site.sh, astroagent tokenFile, .gitignore runtime block).
- Every script carries a parseable @agent-manifest header: name, title, class
  (content|operational|runtime|plumbing), trigger, model, prompts, skills (MCP),
  tools, reads/writes tables. 5 content agents + 3 plumbing scripts.
- New agents/catalog.mjs generates the catalog from the headers:
  agents/AGENTS.md (human, grouped by class) + agents/agents.json (machine
  manifest — a clone diffs it against a source to find missing tools/tables/MCP
  before running). configure.mjs regenerates the catalog on every identity
  stamp. No DB table, no watcher.
- config.json gains paths.stateDir/newsDir; publish-tick, write-daily, and
  news-radar read them instead of hardcoding.
- Full cut: content-pipeline/ deleted (the seed has no live crons, so no
  hybrid period needed). Docs updated (AGENTS.md structure + pipeline section,
  README paths).

Clones migrating from content-pipeline/: see medellin.co's
.memory/handoffs/agents-directory-migration.md for the cutover playbook
(one cron set active at a time; migrate drafts/state after repointing cron).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FMQeUnUrAeexcZ7P2Hxa6G
2026-07-11 17:09:15 -05:00

215 lines
9.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
/**
* @agent-manifest
* name: writer
* title: Daily Blog Writer
* class: content
* trigger: cron:run.sh write-daily.mjs(daily)
* description: Draft one article (MDX + cover + sources) — today's news first, else next evergreen calendar topic.
* model: writer=claude-sonnet-4-6
* prompt: prompts/writer.system.md, prompts/image.md
* skills: mcp__claude_ai_Higgsfield
* tools: lib/claude.mjs, lib/calendar.mjs
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// Daily writer: draft one article (MDX + cover.jpg + sources.json) into drafts/<slug>/.
// Selection order: today's news (news radar) first → else the next evergreen calendar topic.
// Refills the evergreen backlog on demand when it runs low. All calendar writes are atomic.
// Usage: node agents/scripts/write-daily.mjs [slug]
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { runClaude } from "./lib/claude.mjs";
import { loadJson, todayInTz } from "./lib/util.mjs";
import { readCalendar, updateEntry, addEntry } from "./lib/calendar.mjs";
import { runSeoReview } from "./seo-review.mjs";
import { runRevise } from "./revise.mjs";
import { runNewsRadar, pickFreshNews, markNewsUsed } from "./news-radar.mjs";
import { runResearch } from "./research.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const PIPELINE = resolve(HERE, "..");
const cfg = loadJson(join(PIPELINE, "config.json"));
const root = cfg.paths.projectRoot;
if (cfg.author.slug === "REPLACE_ME") {
console.error("[write-daily] config.author is not set. Add the real author first.");
process.exit(1);
}
const calendarPath = join(root, cfg.paths.calendar);
const today = todayInTz(cfg.editorial.timezone);
const stateDir = join(root, cfg.paths.stateDir);
const argSlug = process.argv[2];
let entry;
if (argSlug) {
entry = readCalendar(calendarPath).find((e) => e.slug === argSlug);
if (!entry) {
console.error(`[write-daily] slug not found in calendar: ${argSlug}`);
process.exit(1);
}
} else {
// 0) User suggestions (from the admin dashboard) take top priority.
const suggestion = readCalendar(calendarPath)
.filter((e) => e.suggested && e.status === "planned")
.sort((a, b) => (a.date || "").localeCompare(b.date || ""))[0];
if (suggestion) {
entry = suggestion;
console.log(`[write-daily] suggestion-first: "${entry.workingTitle}"`);
} else {
mkdirSync(stateDir, { recursive: true });
// 1) Ensure today's news scan ran (guarded once/day so the cadence doesn't matter).
if (cfg.news?.enabled) {
const newsStatePath = join(stateDir, `news-${today}.json`);
if (!existsSync(newsStatePath)) {
try {
await runNewsRadar();
} catch (e) {
console.log(`[write-daily] news radar error (continuing): ${e.message}`);
}
writeFileSync(newsStatePath, JSON.stringify({ scanned: today }, null, 2));
}
}
// 2) Demand-driven evergreen refill when the backlog is low (guarded once/day).
const threshold = cfg.editorial.refillThreshold ?? 7;
const plannedCount = readCalendar(calendarPath).filter((e) => e.status === "planned").length;
const refillStatePath = join(stateDir, `research-${today}.json`);
if (plannedCount < threshold && !existsSync(refillStatePath)) {
console.log(`[write-daily] evergreen backlog low (${plannedCount} < ${threshold}) — refilling…`);
try {
const added = await runResearch({ count: cfg.editorial.refillCount ?? 14 });
console.log(`[write-daily] refill added ${added} topics.`);
} catch (e) {
console.log(`[write-daily] refill error (continuing): ${e.message}`);
}
writeFileSync(refillStatePath, JSON.stringify({ refilled: today }, null, 2));
}
// 3) News-first selection; inject the chosen news item as a calendar entry. Else evergreen.
const news = pickFreshNews(cfg);
if (news) {
entry = {
date: today,
slug: news.slug,
workingTitle: news.workingTitle,
type: news.type || "news-roundup",
primaryKeyword: news.primaryKeyword,
secondaryKeywords: news.secondaryKeywords || [],
searchIntent: news.searchIntent || "informational",
audienceAngle: news.newsHook || "",
sourceHints: news.sourceLinks || [],
eeatAngle: "Timely, sourced news — cite every claim.",
news: true,
freshnessScore: news.freshnessScore ?? 3,
status: "planned",
};
await addEntry(calendarPath, entry);
markNewsUsed(news.slug);
console.log(`[write-daily] news-first: "${entry.workingTitle}" (freshness ${entry.freshnessScore})`);
} else {
const cal = readCalendar(calendarPath);
const planned = cal.filter((e) => e.status === "planned");
const due = planned.filter((e) => e.date <= today).sort((a, b) => a.date.localeCompare(b.date));
entry = due[0] || planned.sort((a, b) => a.date.localeCompare(b.date))[0];
}
}
}
if (!entry) {
console.log("[write-daily] Nothing left to write — no fresh news and no planned topics.");
process.exit(0);
}
const draftDir = join(root, cfg.paths.draftsDir, entry.slug);
mkdirSync(draftDir, { recursive: true });
const [wMin, wMax] = cfg.editorial.wordCounts[entry.type] || [800, 1200];
const blogDir = join(root, cfg.paths.blogContentDir);
const logFile = join(root, cfg.paths.logsDir, `write-${entry.slug}.log`);
const prompt = `Write today's Comiida article from this calendar entry:
${JSON.stringify({ ...entry, draft: undefined, instructions: undefined }, null, 2)}
- Today's date: ${today}
- Author slug to byline (exact): ${cfg.author.slug}
- Word count range for type "${entry.type}": ${wMin}${wMax} words
- Draft output directory (write index.mdx, cover.jpg, sources.json here): ${draftDir}
- Existing published posts to read for internal links: ${blogDir}
- Image: use the Higgsfield MCP (preferred model "${cfg.image.preferredModel}", aspect ratio
~${cfg.image.aspectRatio}). Read agents/prompts/image.md and follow that flow
(recommend model → generate_image → job_status sync → curl the result URL).
Save the final image to: ${join(draftDir, "cover.jpg")}.
${
entry.authorFirsthand
? `\nAUTHOR FIRST-HAND PIECE: this is ${cfg.author.name}'s own draft, written from genuine first-hand experience. Follow the "Author first-hand pieces" EXCEPTION in your system prompt — keep the FIRST-PERSON voice and the author's honest opinions (do not neutralize them), and only fact-check/cite objective specifics (addresses, opening dates, prices, hours). SCOPE: the article is about the venues and points the author actually names — cover those; do NOT pad the piece with generic restaurants the author didn't mention just to hit a word count. Aim for the LOWER end of the word-count range; a tight, genuine ~8001000 words beats bloated filler.\n`
: ""
}${
entry.instructions
? `\nEDITOR INSTRUCTIONS (from the person who suggested this — follow them carefully):\n${entry.instructions}\n`
: ""
}${
entry.draft
? `\nEDITOR-PROVIDED DRAFT — use this as the basis for the article. Keep its intent and key points, but fact-check every claim, add real citations, improve structure/SEO, and expand it to meet the contract:\n"""\n${entry.draft}\n"""\n`
: ""
}
Follow the EEAT/SEO contract in your system prompt exactly.`;
await updateEntry(calendarPath, entry.slug, { status: "drafting" });
console.log(`[write-daily] drafting "${entry.workingTitle}" (${entry.type}) → ${draftDir}`);
const res = await runClaude({
prompt,
systemPromptFile: join(PIPELINE, "prompts/writer.system.md"),
model: cfg.models.writer,
// Higgsfield is configured globally via claude.ai (reachable headless) — no local --mcp-config.
allowedTools: "Read Write Edit Glob Grep WebSearch Bash mcp__claude_ai_Higgsfield",
addDirs: [root],
cwd: root,
logFile,
});
console.log(`[write-daily] agent: ${res.result || "(no text)"}`);
const hasMdx = existsSync(join(draftDir, "index.mdx"));
const hasCover = existsSync(join(draftDir, "cover.jpg"));
const finalStatus = hasMdx ? (hasCover ? "drafted" : "drafted-no-image") : "draft-failed";
await updateEntry(calendarPath, entry.slug, { status: finalStatus });
console.log(
`[write-daily] status=${finalStatus} mdx=${hasMdx} cover=${hasCover}\n` +
`[write-daily] review: ${draftDir} | log: ${logFile}`
);
if (!hasMdx) process.exit(1);
// SEO Specialist pass + auto-revise loop, so the approve/publish gate has a verdict.
try {
let audit = await runSeoReview(entry.slug, { published: false });
const minScore = cfg.seo?.minScore ?? 85;
const fails = (a) => !a || a.verdict === "fail" || (a.overall ?? 0) < minScore;
if (audit && cfg.seo?.autoRevise && fails(audit)) {
console.log(`[write-daily] SEO ${audit.verdict} ${audit.overall} < ${minScore} — auto-revising…`);
const { audit: revised, attempts } = await runRevise(entry.slug, {});
if (revised) audit = revised;
console.log(`[write-daily] auto-revise done after ${attempts} attempt(s): ${audit?.verdict} ${audit?.overall}`);
}
if (audit) {
await updateEntry(calendarPath, entry.slug, { seo: { verdict: audit.verdict, overall: audit.overall } });
console.log(
`[write-daily] SEO: ${audit.verdict} · ${audit.overall} · ${(audit.blocking || []).length} blocking`
);
} else {
console.log("[write-daily] SEO: audit could not be generated (continuing).");
}
} catch (e) {
console.log(`[write-daily] SEO review error (continuing): ${e.message}`);
}