#!/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//. // 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 ~800–1000 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}`); }