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
This commit is contained in:
Carlos Arias 2026-07-11 15:33:48 -05:00
parent 41f1b941fa
commit 2c969c0753
38 changed files with 478 additions and 53 deletions

24
.gitignore vendored
View file

@ -1,6 +1,6 @@
# ============================================================================ # ============================================================================
# SeedProject base — cloneable Astro + PHP foundation # SeedProject base — cloneable Astro + PHP foundation
# Tracked: engine code (app/src, api framework, content-pipeline, astroagent). # Tracked: engine code (app/src, api framework, agents, astroagent).
# Ignored: secrets, dependencies, build output, and per-site runtime state. # Ignored: secrets, dependencies, build output, and per-site runtime state.
# ============================================================================ # ============================================================================
@ -25,17 +25,17 @@ app/.astro/
*.log *.log
# --- per-site runtime state (regenerated; dirs kept via .gitkeep) --- # --- per-site runtime state (regenerated; dirs kept via .gitkeep) ---
content-pipeline/logs/* agents/logs/*
!content-pipeline/logs/.gitkeep !agents/logs/.gitkeep
content-pipeline/drafts/* agents/drafts/*
!content-pipeline/drafts/.gitkeep !agents/drafts/.gitkeep
content-pipeline/state/* agents/state/*
!content-pipeline/state/.gitkeep !agents/state/.gitkeep
content-pipeline/calendar.json agents/calendar.json
content-pipeline/messages.json agents/messages.json
content-pipeline/news-queue.json agents/news-queue.json
content-pipeline/news-scan.json agents/news-scan.json
content-pipeline/research-scan.json agents/research-scan.json
# --- astroagent runtime workspaces --- # --- astroagent runtime workspaces ---
.astroagent/jobs/ .astroagent/jobs/

View file

@ -35,7 +35,7 @@ name, URL, description, tagline, author, social, topic, audience, timezone.
configs. configs.
- To change identity: edit `site.config.json`, then run `node scripts/configure.mjs`. - To change identity: edit `site.config.json`, then run `node scripts/configure.mjs`.
That regenerates `app/src/config/site.json` (the theme reads it) and updates That regenerates `app/src/config/site.json` (the theme reads it) and updates
`content-pipeline/config.json` and `astroagent.config.json`. `agents/config.json` and `astroagent.config.json`.
- In the Astro theme, read identity from `SITE`, `authors`, etc. exported by - In the Astro theme, read identity from `SITE`, `authors`, etc. exported by
`app/src/lib/blog-data.js` (which imports `app/src/config/site.json`). `app/src/lib/blog-data.js` (which imports `app/src/config/site.json`).
@ -48,7 +48,7 @@ name, URL, description, tagline, author, social, topic, audience, timezone.
| `scripts/configure.mjs` | Stamp `site.config.json` into every engine | | `scripts/configure.mjs` | Stamp `site.config.json` into every engine |
| `app/` | Astro frontend ("theme"). Build → `../public`. See `app/AGENTS.md` for coding standards. | | `app/` | Astro frontend ("theme"). Build → `../public`. See `app/AGENTS.md` for coding standards. |
| `api/` | SeedProject PHP backend, served at `/api` (see below) | | `api/` | SeedProject PHP backend, served at `/api` (see below) |
| `content-pipeline/` | Autonomous content engine (research → write → review → publish) | | `agents/` | LLM agents: orchestrators + prompts + config. Catalog: `agents/AGENTS.md` (generated) |
| `public/` | Build output (git-ignored) | | `public/` | Build output (git-ignored) |
## Backend (`api/`) ## Backend (`api/`)
@ -72,11 +72,15 @@ php console db:migrate --status
## Content pipeline & authoring ## Content pipeline & authoring
- `content-pipeline/` runs the autonomous content system. `configure.mjs` stamps the - `agents/` runs the autonomous content system. Each agent script carries an
site name/topic/audience/author into `content-pipeline/config.json`, and its runtime `@agent-manifest` header (class, trigger, model, prompts, skills, tools, tables);
state (`drafts/`, `state/`, `logs/`) is git-ignored and regenerates per site. `node agents/catalog.mjs` regenerates the catalog (`agents/AGENTS.md` +
`agents/agents.json` — the machine manifest a clone can diff to find missing deps).
`configure.mjs` stamps the site name/topic/audience/author into `agents/config.json`
and regenerates the catalog; runtime state (`drafts/`, `state/`, `logs/`) is
git-ignored and regenerates per site.
- **⚠️ NOT yet niche-generic — do not trust the pipeline output as-is.** The prompts and - **⚠️ NOT yet niche-generic — do not trust the pipeline output as-is.** The prompts and
some scripts (`content-pipeline/prompts/*.system.md`, `content-pipeline/scripts/*.mjs`) some scripts (`agents/prompts/*.system.md`, `agents/scripts/*.mjs`)
still contain wording from the original site (a Medellín restaurant publication) and do still contain wording from the original site (a Medellín restaurant publication) and do
**not** read the niche from config. Running the pipeline before genericizing it will **not** read the niche from config. Running the pipeline before genericizing it will
produce off-niche content. Fix: make those files read `config.site.{name,topic,audience}`. produce off-niche content. Fix: make those files read `config.site.{name,topic,audience}`.

View file

@ -12,7 +12,7 @@ WordPress install — but faster to host and safer to run.
|------|------|------------------------| |------|------|------------------------|
| `app/` | Astro theme — layouts, components, content collections, sample post | theme + posts | | `app/` | Astro theme — layouts, components, content collections, sample post | theme + posts |
| `api/` | [SeedProject](api/.memory/documentation.md) PHP framework — the dynamic backend (DB, forms, metrics, agents) | PHP core | | `api/` | [SeedProject](api/.memory/documentation.md) PHP framework — the dynamic backend (DB, forms, metrics, agents) | PHP core |
| `content-pipeline/` | Autonomous content engine — research, write, review, publish | (no equivalent) | | `agents/` | Autonomous content engine — research, write, review, publish | (no equivalent) |
| `astroagent.config.json` + `app/.astroagent/` | AI authoring console — change the site in plain English | wp-admin | | `astroagent.config.json` + `app/.astroagent/` | AI authoring console — change the site in plain English | wp-admin |
| `site.config.json` | **Single source of site identity** — the one file you edit per site | wp-config + Site Settings | | `site.config.json` | **Single source of site identity** — the one file you edit per site | wp-config + Site Settings |
@ -55,7 +55,7 @@ node scripts/configure.mjs
``` ```
This stamps the values into the Astro theme (`app/src/config/site.json`), This stamps the values into the Astro theme (`app/src/config/site.json`),
`content-pipeline/config.json`, and `astroagent.config.json` so all three engines `agents/config.json`, and `astroagent.config.json` so all three engines
share one identity. `new-site.sh` runs it for you. share one identity. `new-site.sh` runs it for you.
## Backend (`api/`) ## Backend (`api/`)
@ -78,8 +78,8 @@ The `api/` directory is the SeedProject PHP framework, meant to be served at
## Content pipeline & authoring ## Content pipeline & authoring
- **`content-pipeline/`** — autonomous research/write/review/publish scripts driven - **`agents/`** — autonomous research/write/review/publish scripts driven
by `content-pipeline/config.json` (populated from `site.config.json`). Runtime by `agents/config.json` (populated from `site.config.json`). Runtime
state (`drafts/`, `state/`, `logs/`, queues) is git-ignored and regenerates per site. state (`drafts/`, `state/`, `logs/`, queues) is git-ignored and regenerates per site.
- **astroagent** — the in-site console for making changes in plain English; its - **astroagent** — the in-site console for making changes in plain English; its
identity comes from `astroagent.config.json`. identity comes from `astroagent.config.json`.
@ -94,7 +94,7 @@ my-site/
│ └── configure.mjs ← stamp site.config.json into every engine │ └── configure.mjs ← stamp site.config.json into every engine
├── app/ ← Astro frontend (build → ../public) ├── app/ ← Astro frontend (build → ../public)
├── api/ ← SeedProject PHP backend (served at /api) ├── api/ ← SeedProject PHP backend (served at /api)
├── content-pipeline/ ← autonomous content engine ├── agents/ ← autonomous content engine
└── public/ ← build output (git-ignored) └── public/ ← build output (git-ignored)
``` ```

32
agents/AGENTS.md Normal file
View file

@ -0,0 +1,32 @@
# Agent catalog
> **GENERATED — do not edit.** Regenerate with `node agents/catalog.mjs`
> (source of truth: the `@agent-manifest` header in each script).
An **agent** = an LLM orchestrator script + its role prompt (`prompt:`) + its skills
(MCP servers, `skills:`) + its tools (`api/cli/*.php` bridges and `lib/*.mjs`, `tools:`).
To port an agent to another SeedProject clone, satisfy its manifest: copy its prompts, ensure
its tools exist, migrate each table in `reads:`/`writes:`, configure its MCP servers.
`agents.json` is the machine-readable version — diff it against a clone to find gaps.
## content
Cron/manual-triggered producers of gated drafts (events auto-publishes).
| agent | trigger | model | skills | tools | reads → writes | description |
|---|---|---|---|---|---|---|
| **news-radar** ([agents/scripts/news-radar.mjs](../agents/scripts/news-radar.mjs)) | manual / cron-capable (maintains news-queue.json for the writer) | research=claude-opus-4-8 | — | lib/claude.mjs | — → — | Discover timely niche news and maintain news-queue.json; the daily writer drains it first. |
| **research** ([agents/scripts/research.mjs](../agents/scripts/research.mjs)) | manual (auto-invoked by writer when the evergreen backlog runs low) | research=claude-opus-4-8 | — | lib/claude.mjs, lib/calendar.mjs | — → — | Top up the evergreen backlog in calendar.json (additive, deduped); timely news is the news radar's job. |
| **reviser** ([agents/scripts/revise.mjs](../agents/scripts/revise.mjs)) | manual <slug> (auto-invoked when the SEO gate fails and autoRevise is on) | writer=claude-sonnet-4-6 | — | lib/claude.mjs | — → — | Feed the SEO audit's fixes back to the writer and re-audit until the draft passes or attempts run out. |
| **seo-review** ([agents/scripts/seo-review.mjs](../agents/scripts/seo-review.mjs)) | manual <slug> (auto-invoked by writer/approve gates) | reviewer=claude-opus-4-8 | — | lib/claude.mjs | — → — | Audit one article for EEAT / spam-policy / on-page SEO / AEO / readability → seo-review.json. |
| **writer** ([agents/scripts/write-daily.mjs](../agents/scripts/write-daily.mjs)) | cron:run.sh write-daily.mjs(daily) | writer=claude-sonnet-4-6 | mcp__claude_ai_Higgsfield | lib/claude.mjs, lib/calendar.mjs | — → — | Draft one article (MDX + cover + sources) — today's news first, else next evergreen calendar topic. |
## plumbing
Deterministic helpers — not agents (no LLM), catalogued for completeness.
| agent | trigger | model | skills | tools | reads → writes | description |
|---|---|---|---|---|---|---|
| **approve** ([agents/scripts/approve.mjs](../agents/scripts/approve.mjs)) | manual <slug> [--force] | - | — | lib/publish.mjs | — → — | Approve a draft: SEO gate, then promote it into the blog, build, go live. No LLM. |
| **list-drafts** ([agents/scripts/list-drafts.mjs](../agents/scripts/list-drafts.mjs)) | manual | - | — | — | — → — | List pending drafts awaiting review. No LLM. |
| **publish-tick** ([agents/scripts/publish-tick.mjs](../agents/scripts/publish-tick.mjs)) | cron:run.sh publish-tick.mjs(15m) | - | — | lib/publish.mjs | — → — | Once per day after the morning floor, publish ONE eligible draft (SEO-gated) with a randomized earlier-today timestamp. No LLM. |

View file

@ -93,7 +93,7 @@ const badge = (status) =>
const newsBadge = `<span style="background:#b5179e;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">NEWS</span>`; const newsBadge = `<span style="background:#b5179e;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">NEWS</span>`;
const suggestedBadge = `<span style="background:#7048e8;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">SUGGESTED</span>`; const suggestedBadge = `<span style="background:#7048e8;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">SUGGESTED</span>`;
function freshNewsCount() { function freshNewsCount() {
const p = join(root, "content-pipeline", "news-queue.json"); const p = join(root, "agents", "news-queue.json");
if (!existsSync(p)) return 0; if (!existsSync(p)) return 0;
try { try {
return JSON.parse(readFileSync(p, "utf8")).filter((i) => i.status === "fresh").length; return JSON.parse(readFileSync(p, "utf8")).filter((i) => i.status === "fresh").length;
@ -108,7 +108,7 @@ const seoBadge = (seo) =>
? `<span style="background:${SEO_COLORS[seo.verdict] || "#666"};color:#fff;border-radius:999px;padding:2px 10px;font-size:12px;white-space:nowrap">SEO ${esc(seo.verdict)} ${esc(seo.overall)}</span>` ? `<span style="background:${SEO_COLORS[seo.verdict] || "#666"};color:#fff;border-radius:999px;padding:2px 10px;font-size:12px;white-space:nowrap">SEO ${esc(seo.verdict)} ${esc(seo.overall)}</span>`
: `<span style="background:#aaa;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">SEO —</span>`; : `<span style="background:#aaa;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">SEO —</span>`;
const messagesPath = () => join(root, "content-pipeline", "messages.json"); const messagesPath = () => join(root, "agents", "messages.json");
function messages() { function messages() {
const p = messagesPath(); const p = messagesPath();
if (!existsSync(p)) return []; if (!existsSync(p)) return [];

153
agents/agents.json Normal file
View file

@ -0,0 +1,153 @@
{
"agents": [
{
"file": "agents/scripts/approve.mjs",
"name": "approve",
"title": "Draft Approver",
"class": "plumbing",
"trigger": "manual <slug> [--force]",
"description": "Approve a draft: SEO gate, then promote it into the blog, build, go live. No LLM.",
"model": "-",
"prompt": [],
"skills": [],
"tools": [
"lib/publish.mjs"
],
"reads": [],
"writes": [],
"created": "2026-07-04"
},
{
"file": "agents/scripts/list-drafts.mjs",
"name": "list-drafts",
"title": "Draft Lister",
"class": "plumbing",
"trigger": "manual",
"description": "List pending drafts awaiting review. No LLM.",
"model": "-",
"prompt": [],
"skills": [],
"tools": [],
"reads": [],
"writes": [],
"created": "2026-07-04"
},
{
"file": "agents/scripts/news-radar.mjs",
"name": "news-radar",
"title": "News Radar",
"class": "content",
"trigger": "manual / cron-capable (maintains news-queue.json for the writer)",
"description": "Discover timely niche news and maintain news-queue.json; the daily writer drains it first.",
"model": "research=claude-opus-4-8",
"prompt": [
"prompts/news-radar.system.md"
],
"skills": [],
"tools": [
"lib/claude.mjs"
],
"reads": [],
"writes": [],
"created": "2026-07-04"
},
{
"file": "agents/scripts/publish-tick.mjs",
"name": "publish-tick",
"title": "Auto-Publisher Tick",
"class": "plumbing",
"trigger": "cron:run.sh publish-tick.mjs(15m)",
"description": "Once per day after the morning floor, publish ONE eligible draft (SEO-gated) with a randomized earlier-today timestamp. No LLM.",
"model": "-",
"prompt": [],
"skills": [],
"tools": [
"lib/publish.mjs"
],
"reads": [],
"writes": [],
"created": "2026-07-04"
},
{
"file": "agents/scripts/research.mjs",
"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"
},
{
"file": "agents/scripts/revise.mjs",
"name": "reviser",
"title": "Draft Reviser",
"class": "content",
"trigger": "manual <slug> (auto-invoked when the SEO gate fails and autoRevise is on)",
"description": "Feed the SEO audit's fixes back to the writer and re-audit until the draft passes or attempts run out.",
"model": "writer=claude-sonnet-4-6",
"prompt": [
"prompts/reviser.system.md"
],
"skills": [],
"tools": [
"lib/claude.mjs"
],
"reads": [],
"writes": [],
"created": "2026-07-04"
},
{
"file": "agents/scripts/seo-review.mjs",
"name": "seo-review",
"title": "SEO Reviewer",
"class": "content",
"trigger": "manual <slug> (auto-invoked by writer/approve gates)",
"description": "Audit one article for EEAT / spam-policy / on-page SEO / AEO / readability → seo-review.json.",
"model": "reviewer=claude-opus-4-8",
"prompt": [
"prompts/seo-review.system.md"
],
"skills": [],
"tools": [
"lib/claude.mjs"
],
"reads": [],
"writes": [],
"created": "2026-07-04"
},
{
"file": "agents/scripts/write-daily.mjs",
"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"
}
]
}

98
agents/catalog.mjs Normal file
View file

@ -0,0 +1,98 @@
#!/usr/bin/env node
// catalog.mjs — generate the agent catalog from @agent-manifest headers.
//
// Scans agents/scripts/**/*.mjs plus the runtime PHP agents listed below, extracts each
// fenced `@agent-manifest … @end` block, and emits:
// agents/AGENTS.md — human catalog, one table per class (GENERATED — do not edit)
// agents/agents.json — machine manifest; clones diff this to find missing deps
//
// Run after adding/editing an agent: node agents/catalog.mjs
// (also runs automatically at the end of scripts/configure.mjs)
import { readFileSync, writeFileSync, readdirSync } from "node:fs";
import { dirname, join, relative } from "node:path";
import { fileURLToPath } from "node:url";
const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, "..");
// Runtime agents living outside agents/ (PHP, served over HTTP) — included explicitly.
const RUNTIME_FILES = []; // no runtime PHP agents in the seed base yet
const CLASSES = ["content", "operational", "runtime", "plumbing"];
const LIST_KEYS = ["prompt", "skills", "tools", "reads", "writes"];
function parseManifest(src) {
const m = src.match(/@agent-manifest\s*\n([\s\S]*?)\n\s*\*?\s*@end/);
if (!m) return null;
const out = {};
for (const raw of m[1].split("\n")) {
const line = raw.replace(/^\s*\*\s?/, ""); // strip comment leader
const kv = line.match(/^([a-z-]+):\s*(.*)$/);
if (kv) out[kv[1]] = kv[2].trim();
}
for (const k of LIST_KEYS) {
const v = out[k];
out[k] = !v || v === "-" ? [] : v.split(",").map((s) => s.trim()).filter(Boolean);
}
return out;
}
const files = readdirSync(join(HERE, "scripts"))
.filter((f) => f.endsWith(".mjs"))
.map((f) => join("agents", "scripts", f))
.concat(RUNTIME_FILES);
const agents = [];
for (const rel of files.sort()) {
const manifest = parseManifest(readFileSync(join(ROOT, rel), "utf8"));
if (!manifest) continue; // no manifest → not an agent (e.g. future helpers)
if (!manifest.name || !manifest.class) {
console.error(`[catalog] ${rel}: manifest missing name/class — skipped`);
continue;
}
if (!CLASSES.includes(manifest.class)) {
console.error(`[catalog] ${rel}: unknown class "${manifest.class}" — skipped`);
continue;
}
agents.push({ file: rel, ...manifest });
}
agents.sort((a, b) => a.name.localeCompare(b.name));
// ---- agents.json (machine manifest) ----
writeFileSync(join(HERE, "agents.json"), JSON.stringify({ agents }, null, 2) + "\n");
// ---- AGENTS.md (human catalog) ----
const esc = (s) => String(s).replace(/\|/g, "\\|");
const classBlurb = {
content: "Cron/manual-triggered producers of gated drafts (events auto-publishes).",
operational: "Event/queue-triggered reviewers and responders (gated + logged).",
runtime: "HTTP-triggered agents answering live user requests.",
plumbing: "Deterministic helpers — not agents (no LLM), catalogued for completeness.",
};
let md = `# Agent catalog
> **GENERATED do not edit.** Regenerate with \`node agents/catalog.mjs\`
> (source of truth: the \`@agent-manifest\` header in each script).
An **agent** = an LLM orchestrator script + its role prompt (\`prompt:\`) + its skills
(MCP servers, \`skills:\`) + its tools (\`api/cli/*.php\` bridges and \`lib/*.mjs\`, \`tools:\`).
To port an agent to another SeedProject clone, satisfy its manifest: copy its prompts, ensure
its tools exist, migrate each table in \`reads:\`/\`writes:\`, configure its MCP servers.
\`agents.json\` is the machine-readable version — diff it against a clone to find gaps.
`;
for (const cls of CLASSES) {
const group = agents.filter((a) => a.class === cls);
if (!group.length) continue;
md += `\n## ${cls}\n\n${classBlurb[cls]}\n\n`;
md += `| agent | trigger | model | skills | tools | reads → writes | description |\n`;
md += `|---|---|---|---|---|---|---|\n`;
for (const a of group) {
const rw = `${a.reads.join(", ") || "—"}${a.writes.join(", ") || "—"}`;
md += `| **${esc(a.name)}** ([${esc(a.file)}](../${a.file})) | ${esc(a.trigger || "—")} | ${esc(a.model || "—")} | ${esc(a.skills.join(", ") || "—")} | ${esc(a.tools.join(", ") || "—")} | ${esc(rw)} | ${esc(a.description || "")} |\n`;
}
}
writeFileSync(join(HERE, "AGENTS.md"), md);
console.log(`[catalog] ${agents.length} manifests → agents/AGENTS.md + agents/agents.json`);

View file

@ -11,9 +11,11 @@
"appDir": "app", "appDir": "app",
"blogContentDir": "app/src/content/blog", "blogContentDir": "app/src/content/blog",
"blogDataFile": "app/src/lib/blog-data.js", "blogDataFile": "app/src/lib/blog-data.js",
"calendar": "content-pipeline/calendar.json", "calendar": "agents/calendar.json",
"draftsDir": "content-pipeline/drafts", "draftsDir": "agents/drafts",
"logsDir": "content-pipeline/logs" "logsDir": "agents/logs",
"stateDir": "agents/state",
"newsDir": "agents"
}, },
"models": { "models": {
"research": "claude-opus-4-8", "research": "claude-opus-4-8",

View file

@ -54,7 +54,7 @@ output is a reviewable draft — it does not go live until a human approves it.
the primary keyword. the primary keyword.
## Output — write exactly these files into the draft directory ## Output — write exactly these files into the draft directory
The user prompt gives you the draft dir as `content-pipeline/drafts/<slug>/`. The user prompt gives you the draft dir as `agents/drafts/<slug>/`.
**1. `index.mdx`** — frontmatter must match the Astro schema EXACTLY, then the body: **1. `index.mdx`** — frontmatter must match the Astro schema EXACTLY, then the body:
```mdx ```mdx

View file

@ -1,6 +1,22 @@
#!/usr/bin/env node #!/usr/bin/env node
/**
* @agent-manifest
* name: approve
* title: Draft Approver
* class: plumbing
* trigger: manual <slug> [--force]
* description: Approve a draft: SEO gate, then promote it into the blog, build, go live. No LLM.
* model: -
* prompt: -
* skills: -
* tools: lib/publish.mjs
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// Manually approve a draft: SEO gate, then promote it into the blog, build, go live. // Manually approve a draft: SEO gate, then promote it into the blog, build, go live.
// Usage: node content-pipeline/scripts/approve.mjs <slug> [--force] // Usage: node agents/scripts/approve.mjs <slug> [--force]
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { dirname, resolve, join } from "node:path"; import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";

View file

@ -15,7 +15,7 @@ function run(cmd, args, opts = {}) {
/** Absolute path of the global build lock. Callers that build outside buildSite() /** Absolute path of the global build lock. Callers that build outside buildSite()
* (e.g. console preview builds) must take THIS same lock to serialize. */ * (e.g. console preview builds) must take THIS same lock to serialize. */
export function buildLockPath(projectRoot) { export function buildLockPath(projectRoot) {
return `${projectRoot}/content-pipeline/state/build.lock`; return `${projectRoot}/agents/state/build.lock`;
} }
/** /**
@ -25,7 +25,7 @@ export function buildLockPath(projectRoot) {
*/ */
export async function buildSite({ projectRoot, appDir }) { export async function buildSite({ projectRoot, appDir }) {
const lockPath = buildLockPath(projectRoot); const lockPath = buildLockPath(projectRoot);
mkdirSync(`${projectRoot}/content-pipeline/state`, { recursive: true }); mkdirSync(`${projectRoot}/agents/state`, { recursive: true });
await withLock(lockPath, async () => { await withLock(lockPath, async () => {
await run("npm", ["run", "build"], { cwd: `${projectRoot}/${appDir}` }); await run("npm", ["run", "build"], { cwd: `${projectRoot}/${appDir}` });
// Best-effort ownership fix (ignore failure if not running as root). // Best-effort ownership fix (ignore failure if not running as root).

View file

@ -1,6 +1,22 @@
#!/usr/bin/env node #!/usr/bin/env node
/**
* @agent-manifest
* name: list-drafts
* title: Draft Lister
* class: plumbing
* trigger: manual
* description: List pending drafts awaiting review. No LLM.
* model: -
* prompt: -
* skills: -
* tools: -
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// List pending drafts awaiting review. // List pending drafts awaiting review.
// Usage: node content-pipeline/scripts/list-drafts.mjs // Usage: node agents/scripts/list-drafts.mjs
import { readdirSync, existsSync, readFileSync } from "node:fs"; import { readdirSync, existsSync, readFileSync } from "node:fs";
import { dirname, resolve, join } from "node:path"; import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -34,5 +50,5 @@ for (const d of dirs) {
} }
console.log(`${d.name}`); console.log(`${d.name}`);
console.log(` ${title}${cat ? ` [${cat}]` : ""} cover:${hasCover ? "yes" : "NO"}`); console.log(` ${title}${cat ? ` [${cat}]` : ""} cover:${hasCover ? "yes" : "NO"}`);
console.log(` approve: node content-pipeline/scripts/approve.mjs ${d.name}\n`); console.log(` approve: node agents/scripts/approve.mjs ${d.name}\n`);
} }

View file

@ -1,6 +1,22 @@
#!/usr/bin/env node #!/usr/bin/env node
/**
* @agent-manifest
* name: news-radar
* title: News Radar
* class: content
* trigger: manual / cron-capable (maintains news-queue.json for the writer)
* description: Discover timely niche news and maintain news-queue.json; the daily writer drains it first.
* model: research=claude-opus-4-8
* prompt: prompts/news-radar.system.md
* skills: -
* tools: lib/claude.mjs
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// News radar: discover timely Medellín food news/events and maintain news-queue.json. // News radar: discover timely Medellín food news/events and maintain news-queue.json.
// Usage: node content-pipeline/scripts/news-radar.mjs // Usage: node agents/scripts/news-radar.mjs
import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs"; import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
import { dirname, resolve, join } from "node:path"; import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -12,8 +28,8 @@ const PIPELINE = resolve(HERE, "..");
const cfg = loadJson(join(PIPELINE, "config.json")); const cfg = loadJson(join(PIPELINE, "config.json"));
const root = cfg.paths.projectRoot; const root = cfg.paths.projectRoot;
const queuePath = join(root, "content-pipeline", "news-queue.json"); const queuePath = join(root, cfg.paths.newsDir, "news-queue.json");
const scanPath = join(root, "content-pipeline", "news-scan.json"); const scanPath = join(root, cfg.paths.newsDir, "news-scan.json");
const loadArr = (p) => (existsSync(p) ? loadJson(p) : []); const loadArr = (p) => (existsSync(p) ? loadJson(p) : []);
const publishedSlugs = () => { const publishedSlugs = () => {

View file

@ -1,9 +1,25 @@
#!/usr/bin/env node #!/usr/bin/env node
/**
* @agent-manifest
* name: publish-tick
* title: Auto-Publisher Tick
* class: plumbing
* trigger: cron:run.sh publish-tick.mjs(15m)
* description: Once per day after the morning floor, publish ONE eligible draft (SEO-gated) with a randomized earlier-today timestamp. No LLM.
* model: -
* prompt: -
* skills: -
* tools: lib/publish.mjs
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// Auto-publisher. Run frequently by cron (e.g. every 15 min). Once per day, after a morning // Auto-publisher. Run frequently by cron (e.g. every 15 min). Once per day, after a morning
// floor time, it publishes ONE eligible draft (passing the SEO gate) so posts go live in the // floor time, it publishes ONE eligible draft (passing the SEO gate) so posts go live in the
// morning — but stamps each with a RANDOM earlier-today timestamp so published times aren't a // morning — but stamps each with a RANDOM earlier-today timestamp so published times aren't a
// fixed-minute metronome. News drafts are fast-tracked ahead of evergreen. // fixed-minute metronome. News drafts are fast-tracked ahead of evergreen.
// Usage: node content-pipeline/scripts/publish-tick.mjs [--now] (--now ignores the floor) // Usage: node agents/scripts/publish-tick.mjs [--now] (--now ignores the floor)
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs"; import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
import { dirname, resolve, join } from "node:path"; import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -22,7 +38,7 @@ if (!cfg.publish?.auto) {
} }
const today = todayInTz(cfg.editorial.timezone); const today = todayInTz(cfg.editorial.timezone);
const stateDir = join(root, "content-pipeline", "state"); const stateDir = join(root, cfg.paths.stateDir);
mkdirSync(stateDir, { recursive: true }); mkdirSync(stateDir, { recursive: true });
const statePath = join(stateDir, `publish-${today}.json`); const statePath = join(stateDir, `publish-${today}.json`);

View file

@ -1,7 +1,23 @@
#!/usr/bin/env node #!/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). // Editorial research: top up the EVERGREEN backlog in calendar.json (additive, deduped).
// Timely news is handled separately by the news radar. Usage: // Timely news is handled separately by the news radar. Usage:
// node content-pipeline/scripts/research.mjs [count] // node agents/scripts/research.mjs [count]
import { readdirSync, existsSync } from "node:fs"; import { readdirSync, existsSync } from "node:fs";
import { dirname, resolve, join } from "node:path"; import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -26,7 +42,7 @@ export async function runResearch({ count } = {}) {
const n = count || cfg.editorial.calendarDays; const n = count || cfg.editorial.calendarDays;
const today = todayInTz(cfg.editorial.timezone); const today = todayInTz(cfg.editorial.timezone);
const calendarPath = join(root, cfg.paths.calendar); const calendarPath = join(root, cfg.paths.calendar);
const scanPath = join(root, "content-pipeline", "research-scan.json"); const scanPath = join(root, "agents", "research-scan.json");
// Per-type target counts from the configured mix. // Per-type target counts from the configured mix.
const types = Object.keys(cfg.editorial.contentMix); const types = Object.keys(cfg.editorial.contentMix);

View file

@ -1,7 +1,23 @@
#!/usr/bin/env node #!/usr/bin/env node
/**
* @agent-manifest
* name: reviser
* title: Draft Reviser
* class: content
* trigger: manual <slug> (auto-invoked when the SEO gate fails and autoRevise is on)
* description: Feed the SEO audit's fixes back to the writer and re-audit until the draft passes or attempts run out.
* model: writer=claude-sonnet-4-6
* prompt: prompts/reviser.system.md
* skills: -
* tools: lib/claude.mjs
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// Auto-revise loop: feed the SEO audit's fixes back to the writer, re-audit, repeat until the // Auto-revise loop: feed the SEO audit's fixes back to the writer, re-audit, repeat until the
// draft passes the gate or maxReviseAttempts is reached. // draft passes the gate or maxReviseAttempts is reached.
// Usage: node content-pipeline/scripts/revise.mjs <slug> // Usage: node agents/scripts/revise.mjs <slug>
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { dirname, resolve, join } from "node:path"; import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";

View file

@ -1,8 +1,24 @@
#!/usr/bin/env node #!/usr/bin/env node
/**
* @agent-manifest
* name: seo-review
* title: SEO Reviewer
* class: content
* trigger: manual <slug> (auto-invoked by writer/approve gates)
* description: Audit one article for EEAT / spam-policy / on-page SEO / AEO / readability seo-review.json.
* model: reviewer=claude-opus-4-8
* prompt: prompts/seo-review.system.md
* skills: -
* tools: lib/claude.mjs
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// SEO Specialist agent: audit one article for EEAT / spam-policy / on-page SEO / AEO / readability. // SEO Specialist agent: audit one article for EEAT / spam-policy / on-page SEO / AEO / readability.
// Writes <postDir>/seo-review.json. Usage: // Writes <postDir>/seo-review.json. Usage:
// node scripts/seo-review.mjs <slug> [--published] // node scripts/seo-review.mjs <slug> [--published]
// (default: review the draft in content-pipeline/drafts/<slug>; --published reviews the // (default: review the draft in agents/drafts/<slug>; --published reviews the
// live post in app/src/content/blog/<slug>) // live post in app/src/content/blog/<slug>)
import { existsSync, readFileSync } from "node:fs"; import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve, join } from "node:path"; import { dirname, resolve, join } from "node:path";

View file

@ -1,8 +1,24 @@
#!/usr/bin/env node #!/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>/. // 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. // 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. // Refills the evergreen backlog on demand when it runs low. All calendar writes are atomic.
// Usage: node content-pipeline/scripts/write-daily.mjs [slug] // Usage: node agents/scripts/write-daily.mjs [slug]
import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve, join } from "node:path"; import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -26,7 +42,7 @@ if (cfg.author.slug === "REPLACE_ME") {
const calendarPath = join(root, cfg.paths.calendar); const calendarPath = join(root, cfg.paths.calendar);
const today = todayInTz(cfg.editorial.timezone); const today = todayInTz(cfg.editorial.timezone);
const stateDir = join(root, "content-pipeline", "state"); const stateDir = join(root, cfg.paths.stateDir);
const argSlug = process.argv[2]; const argSlug = process.argv[2];
let entry; let entry;
@ -128,7 +144,7 @@ ${JSON.stringify({ ...entry, draft: undefined, instructions: undefined }, null,
- Draft output directory (write index.mdx, cover.jpg, sources.json here): ${draftDir} - Draft output directory (write index.mdx, cover.jpg, sources.json here): ${draftDir}
- Existing published posts to read for internal links: ${blogDir} - Existing published posts to read for internal links: ${blogDir}
- Image: use the Higgsfield MCP (preferred model "${cfg.image.preferredModel}", aspect ratio - Image: use the Higgsfield MCP (preferred model "${cfg.image.preferredModel}", aspect ratio
~${cfg.image.aspectRatio}). Read content-pipeline/prompts/image.md and follow that flow ~${cfg.image.aspectRatio}). Read agents/prompts/image.md and follow that flow
(recommend model generate_image job_status sync curl the result URL). (recommend model generate_image job_status sync curl the result URL).
Save the final image to: ${join(draftDir, "cover.jpg")}. Save the final image to: ${join(draftDir, "cover.jpg")}.
${ ${

View file

@ -23,7 +23,7 @@
"console": { "console": {
"port": 3011, "port": 3011,
"route": "/devconsole", "route": "/devconsole",
"tokenFile": "content-pipeline/.env", "tokenFile": "agents/.env",
"tokenKey": "ADMIN_TOKEN" "tokenKey": "ADMIN_TOKEN"
} }
} }

View file

@ -7,8 +7,10 @@
* *
* Writes: * Writes:
* - app/src/config/site.json (Astro theme reads this) * - app/src/config/site.json (Astro theme reads this)
* - content-pipeline/config.json (site identity + author + timezone) * - agents/config.json (site identity + author + timezone)
* - astroagent.config.json (name + url) * - astroagent.config.json (name + url)
*
* Finishes by regenerating the agent catalog (agents/AGENTS.md + agents.json).
*/ */
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
@ -38,15 +40,15 @@ write("app/src/config/site.json", {
social: site.social, social: site.social,
}); });
// 2) content-pipeline // 2) agents
if (existsSync(resolve(root, "content-pipeline/config.json"))) { if (existsSync(resolve(root, "agents/config.json"))) {
const cp = read("content-pipeline/config.json"); const cp = read("agents/config.json");
cp.site = { ...cp.site, url: site.url, name: site.name, topic: site.topic, audience: site.audience, language: site.language }; cp.site = { ...cp.site, url: site.url, name: site.name, topic: site.topic, audience: site.audience, language: site.language };
cp.author = { ...(cp.author || {}), slug: site.author.slug, name: site.author.name }; cp.author = { ...(cp.author || {}), slug: site.author.slug, name: site.author.name };
if (cp.paths) cp.paths.projectRoot = root; // this clone's absolute path, not comiida's if (cp.paths) cp.paths.projectRoot = root; // this clone's absolute path, not comiida's
if (cp.editorial) cp.editorial.timezone = site.timezone; if (cp.editorial) cp.editorial.timezone = site.timezone;
if (cp.image && cp.image.credit) cp.image.credit.author = site.name; if (cp.image && cp.image.credit) cp.image.credit.author = site.name;
write("content-pipeline/config.json", cp); write("agents/config.json", cp);
} }
// 3) astroagent // 3) astroagent
@ -58,4 +60,10 @@ if (existsSync(resolve(root, "astroagent.config.json"))) {
write("astroagent.config.json", aa); write("astroagent.config.json", aa);
} }
// 4) agent catalog (AGENTS.md + agents.json reflect this clone's identity)
if (existsSync(resolve(root, "agents/catalog.mjs"))) {
const { execFileSync } = await import("node:child_process");
execFileSync("node", [resolve(root, "agents/catalog.mjs")], { stdio: "inherit" });
}
console.log("Done. Rebuild the site: (cd app && npm run build)"); console.log("Done. Rebuild the site: (cd app && npm run build)");

View file

@ -6,7 +6,7 @@
# ./scripts/new-site.sh # ./scripts/new-site.sh
# #
# Prompts for this site's identity, writes site.config.json, stamps it across # Prompts for this site's identity, writes site.config.json, stamps it across
# every engine (Astro theme, content-pipeline, astroagent), optionally resets # every engine (Astro theme, agents, astroagent), optionally resets
# git history and installs dependencies. # git history and installs dependencies.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
set -euo pipefail set -euo pipefail