2026-07-04 22:53:10 +00:00
#!/usr/bin/env node
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 20:33:48 +00:00
/ * *
* @ 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
* /
2026-07-04 22:53:10 +00:00
// 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.
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 20:33:48 +00:00
// Usage: node agents/scripts/write-daily.mjs [slug]
2026-07-04 22:53:10 +00:00
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 ) ;
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 20:33:48 +00:00
const stateDir = join ( root , cfg . paths . stateDir ) ;
2026-07-04 22:53:10 +00:00
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
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 20:33:48 +00:00
~ $ { cfg . image . aspectRatio } ) . Read agents / prompts / image . md and follow that flow
2026-07-04 22:53:10 +00:00
( recommend model → generate _image → job _status sync → curl the result URL ) .
Save the final image to : $ { join ( draftDir , "cover.jpg" ) } .
$ {
entry . authorFirsthand
? ` \n AUTHOR 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
? ` \n EDITOR INSTRUCTIONS (from the person who suggested this — follow them carefully): \n ${ entry . instructions } \n `
: ""
} $ {
entry . draft
? ` \n EDITOR-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 } ` ) ;
}