framework(core): per-project agent roster + unified registry

Phase 3.
- server.mjs loads only site.agents.enabled, passing per-agent settings
- each console agent exports a manifest + register(host, settings); QA reads
  heartbeatMin/autofix from settings (env fallback)
- catalog.mjs folds the runtime (console) agents into agents.json/AGENTS.md via
  their manifest exports — one registry for pipeline + runtime agents
Verified: disabling 'qa' in the roster removes /qa/run + the heartbeat.
This commit is contained in:
Carlos Arias 2026-07-24 08:03:00 -04:00
parent 7799fd62e3
commit f287f74448
9 changed files with 243 additions and 32 deletions

View file

@ -21,6 +21,17 @@ Cron/manual-triggered producers of gated drafts (events auto-publishes).
| **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. |
## runtime
HTTP-triggered agents answering live user requests.
| agent | trigger | model | skills | tools | reads → writes | description |
|---|---|---|---|---|---|---|
| **in-page-console** ([agents/console/agents/in-page-console.mjs](../agents/console/agents/in-page-console.mjs)) | POST /run; GET /stream; POST /publish; POST /rebuild; POST /discard | claude-sonnet-4-6 | — | Read, Write, Edit, Glob, Grep, WebSearch | — → — | Freeform ▲ editor: describe a change on any page; the agent edits, the console builds an isolated preview, then publish/discard. |
| **project-builder** ([agents/console/agents/project-builder.mjs](../agents/console/agents/project-builder.mjs)) | POST /draft; POST /build-project | claude-sonnet-4-6 | — | Read | cja_projects → cja_projects | Drafts copy and authors a full structured project record (reads uploaded images) for the New Project admin form. Returns JSON; never mutates the repo. |
| **qa** ([agents/console/agents/qa.mjs](../agents/console/agents/qa.mjs)) | POST /qa/run; schedule(heartbeatMin) | claude-sonnet-4-6 | qa | Read, Skill, api/cli/qa-start.php, api/cli/qa-finish.php, api/cli/qa-routes.php, api/cli/qa-autofix.php | cja_qa_runs, cja_qa_findings → cja_qa_runs, cja_qa_findings | Crawls the live site (links, images, forms, API, SEO/meta, a11y); auto-fixes safe findings via the Web Designer queue; runs on a heartbeat. |
| **web-designer** ([agents/console/agents/web-designer.mjs](../agents/console/agents/web-designer.mjs)) | POST /tasks/run | claude-sonnet-4-6 | brand, ui-ux, changelog | Read, Write, Edit, Glob, Grep, WebSearch, Skill, api/cli/tasks-next.php, api/cli/tasks-finish.php, api/cli/seed-changelog.php | cja_tasks, cja_changelog, cja_projects → cja_tasks, cja_changelog, cja_projects | Durable design queue (cja_tasks): runs the full-builder agent on any page from a brief, auto-publishes (build-gated scoped commit), and self-logs to the changelog. Fed by the admin and by QA autofix. |
## plumbing
Deterministic helpers — not agents (no LLM), catalogued for completeness.

View file

@ -17,6 +17,28 @@
"writes": [],
"created": "2026-07-04"
},
{
"file": "agents/console/agents/in-page-console.mjs",
"name": "in-page-console",
"title": "In-page Console",
"class": "runtime",
"trigger": "POST /run; GET /stream; POST /publish; POST /rebuild; POST /discard",
"model": "claude-sonnet-4-6",
"description": "Freeform ▲ editor: describe a change on any page; the agent edits, the console builds an isolated preview, then publish/discard.",
"prompt": [],
"skills": [],
"tools": [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"WebSearch"
],
"reads": [],
"writes": [],
"created": ""
},
{
"file": "agents/scripts/list-drafts.mjs",
"name": "list-drafts",
@ -51,6 +73,27 @@
"writes": [],
"created": "2026-07-04"
},
{
"file": "agents/console/agents/project-builder.mjs",
"name": "project-builder",
"title": "Project Builder",
"class": "runtime",
"trigger": "POST /draft; POST /build-project",
"model": "claude-sonnet-4-6",
"description": "Drafts copy and authors a full structured project record (reads uploaded images) for the New Project admin form. Returns JSON; never mutates the repo.",
"prompt": [],
"skills": [],
"tools": [
"Read"
],
"reads": [
"cja_projects"
],
"writes": [
"cja_projects"
],
"created": ""
},
{
"file": "agents/scripts/publish-tick.mjs",
"name": "publish-tick",
@ -68,6 +111,36 @@
"writes": [],
"created": "2026-07-04"
},
{
"file": "agents/console/agents/qa.mjs",
"name": "qa",
"title": "QA Agent",
"class": "runtime",
"trigger": "POST /qa/run; schedule(heartbeatMin)",
"model": "claude-sonnet-4-6",
"description": "Crawls the live site (links, images, forms, API, SEO/meta, a11y); auto-fixes safe findings via the Web Designer queue; runs on a heartbeat.",
"prompt": [],
"skills": [
"qa"
],
"tools": [
"Read",
"Skill",
"api/cli/qa-start.php",
"api/cli/qa-finish.php",
"api/cli/qa-routes.php",
"api/cli/qa-autofix.php"
],
"reads": [
"cja_qa_runs",
"cja_qa_findings"
],
"writes": [
"cja_qa_runs",
"cja_qa_findings"
],
"created": ""
},
{
"file": "agents/scripts/research.mjs",
"name": "research",
@ -126,6 +199,44 @@
"writes": [],
"created": "2026-07-04"
},
{
"file": "agents/console/agents/web-designer.mjs",
"name": "web-designer",
"title": "Web Designer",
"class": "runtime",
"trigger": "POST /tasks/run",
"model": "claude-sonnet-4-6",
"description": "Durable design queue (cja_tasks): runs the full-builder agent on any page from a brief, auto-publishes (build-gated scoped commit), and self-logs to the changelog. Fed by the admin and by QA autofix.",
"prompt": [],
"skills": [
"brand",
"ui-ux",
"changelog"
],
"tools": [
"Read",
"Write",
"Edit",
"Glob",
"Grep",
"WebSearch",
"Skill",
"api/cli/tasks-next.php",
"api/cli/tasks-finish.php",
"api/cli/seed-changelog.php"
],
"reads": [
"cja_tasks",
"cja_changelog",
"cja_projects"
],
"writes": [
"cja_tasks",
"cja_changelog",
"cja_projects"
],
"created": ""
},
{
"file": "agents/scripts/write-daily.mjs",
"name": "writer",

View file

@ -10,13 +10,14 @@
// (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";
import { fileURLToPath, pathToFileURL } 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
// Runtime agents living outside agents/scripts (the console runner's pluggable
// modules) — discovered from their JS `manifest` export (see the loop below).
const RUNTIME_FILES = []; // legacy seam for PHP runtime agents; console agents handled below
const CLASSES = ["content", "operational", "runtime", "plumbing"];
const LIST_KEYS = ["prompt", "skills", "tools", "reads", "writes"];
@ -56,6 +57,35 @@ for (const rel of files.sort()) {
}
agents.push({ file: rel, ...manifest });
}
// ---- runtime (console) agents: JS `manifest` exports ----
// Each agents/console/agents/*.mjs exports a `manifest` object. Importing a
// module only defines it (register()/listen() aren't called), so this is safe.
const consoleDir = join(HERE, "console", "agents");
let aiModel = "-";
try { aiModel = JSON.parse(readFileSync(join(ROOT, "astroagent.config.json"), "utf8")).ai?.model || "-"; } catch {}
let consoleFiles = [];
try { consoleFiles = readdirSync(consoleDir).filter((f) => f.endsWith(".mjs")); } catch {}
for (const f of consoleFiles.sort()) {
const rel = ["agents", "console", "agents", f].join("/");
let mod;
try { mod = await import(pathToFileURL(join(consoleDir, f)).href); }
catch (e) { console.error(`[catalog] ${rel}: import failed — ${e.message}`); continue; }
const m = mod.manifest;
if (!m || !m.name || !m.class) continue;
const trigger = (m.triggers || []).map((t) =>
t.type === "endpoint" ? `${t.method} ${t.path}` : t.type === "schedule" ? `schedule(${t.settingKey})` : t.type
).join("; ");
const toolsArr = typeof m.tools === "string" ? m.tools.split(/\s+/).filter(Boolean) : (m.tools || []);
agents.push({
file: rel, name: m.name, title: m.title || m.name, class: m.class,
trigger, model: aiModel, description: m.description || "",
prompt: [], skills: m.skills || [],
tools: [...toolsArr, ...(m.cli || []).map((c) => `api/cli/${c}.php`)],
reads: m.tables || [], writes: m.tables || [], created: "",
});
}
agents.sort((a, b) => a.name.localeCompare(b.name));
// ---- agents.json (machine manifest) ----

View file

@ -7,6 +7,19 @@
import { site } from "../site.mjs";
export const manifest = {
name: "in-page-console", title: "In-page Console", class: "runtime",
description: "Freeform ▲ editor: describe a change on any page; the agent edits, the console builds an isolated preview, then publish/discard.",
triggers: [
{ type: "endpoint", method: "POST", path: "/run" },
{ type: "endpoint", method: "GET", path: "/stream" },
{ type: "endpoint", method: "POST", path: "/publish" },
{ type: "endpoint", method: "POST", path: "/rebuild" },
{ type: "endpoint", method: "POST", path: "/discard" },
],
tools: site.tools, skills: [], tables: [], cli: [],
};
let H; // the host, set in register()
function buildPrompt({ message, page, selections }) {
@ -103,7 +116,7 @@ function runAgent(job, { message, page, selections, model }) {
});
}
export function register(host) {
export function register(host, settings = {}) {
H = host;
host.route("POST", "/run", async (req, res) => {

View file

@ -6,6 +6,16 @@
import { site } from "../site.mjs";
export const manifest = {
name: "project-builder", title: "Project Builder", class: "runtime",
description: "Drafts copy and authors a full structured project record (reads uploaded images) for the New Project admin form. Returns JSON; never mutates the repo.",
triggers: [
{ type: "endpoint", method: "POST", path: "/draft" },
{ type: "endpoint", method: "POST", path: "/build-project" },
],
tools: "Read", skills: [], tables: ["cja_projects"], cli: [],
};
let H;
const MEDIA_RE = /^\/media\/[\w.-]+$/;
@ -114,7 +124,7 @@ async function buildProject({ name, category, draft, prompt, images, instagram }
} catch { return { ok: false }; }
}
export function register(host) {
export function register(host, settings = {}) {
H = host;
host.route("POST", "/draft", async (req, res) => {

View file

@ -11,11 +11,24 @@ import { site } from "../site.mjs";
let H;
export const manifest = {
name: "qa", title: "QA Agent", class: "runtime",
description: "Crawls the live site (links, images, forms, API, SEO/meta, a11y); auto-fixes safe findings via the Web Designer queue; runs on a heartbeat.",
triggers: [
{ type: "endpoint", method: "POST", path: "/qa/run" },
{ type: "schedule", settingKey: "heartbeatMin" },
],
tools: "Read Skill", skills: ["qa"],
tables: ["cja_qa_runs", "cja_qa_findings"],
cli: ["qa-start", "qa-finish", "qa-routes", "qa-autofix"],
};
const QA_BASE = site.url;
const QA_UA = `Mozilla/5.0 (compatible; AstroAgentQA/1.0; +${site.url})`;
const QA_STATIC_ROUTES = site.qaRoutes;
const QA_AUTOFIX = (process.env.QA_AUTOFIX ?? "1") !== "0";
const QA_HEARTBEAT_MIN = Number(process.env.QA_HEARTBEAT_MIN || 60);
const QA_AUTOFIX_ENV = (process.env.QA_AUTOFIX ?? "1") !== "0";
const QA_HEARTBEAT_ENV = Number(process.env.QA_HEARTBEAT_MIN || 60);
let qaAutofix = QA_AUTOFIX_ENV; // effective; set from settings in register()
let qaRunning = false;
async function probe(url, { method = "GET", readBody = false, timeout = 12000, headers = {}, body = null } = {}) {
@ -203,7 +216,7 @@ async function runQaFlow(trigger) {
await H.phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=done", `--summary=${summary}`, `--counts=${JSON.stringify(counts)}`, `--findings-file=${tmp}`]);
rmSync(tmp, { force: true });
if (QA_AUTOFIX) {
if (qaAutofix) {
const af = await H.phpCli(["api/cli/qa-autofix.php", `--run=${runId}`]);
let queued = 0; try { queued = JSON.parse(af.out || "{}").queued || 0; } catch {}
if (queued > 0 && H.drainTasks) H.drainTasks(); // fire-and-forget
@ -215,8 +228,10 @@ async function runQaFlow(trigger) {
}
}
export function register(host) {
export function register(host, settings = {}) {
H = host;
qaAutofix = settings.autofix ?? QA_AUTOFIX_ENV;
const heartbeatMin = settings.heartbeatMin ?? QA_HEARTBEAT_ENV;
host.route("POST", "/qa/run", async (req, res) => {
if (qaRunning) return H.json(res, 429, { error: "A QA run is already in progress." });
@ -226,8 +241,8 @@ export function register(host) {
H.json(res, 200, { ok: true });
});
if (QA_HEARTBEAT_MIN > 0) {
host.everyMinutes(QA_HEARTBEAT_MIN, () => runQaFlow("scheduled"), { bootDelayMs: 90_000 });
console.log(`[qa] heartbeat every ${QA_HEARTBEAT_MIN} min`);
if (heartbeatMin > 0) {
host.everyMinutes(heartbeatMin, () => runQaFlow("scheduled"), { bootDelayMs: 90_000 });
console.log(`[qa] heartbeat every ${heartbeatMin} min`);
}
}

View file

@ -7,6 +7,16 @@
import { site } from "../site.mjs";
export const manifest = {
name: "web-designer", title: "Web Designer", class: "runtime",
description: "Durable design queue (cja_tasks): runs the full-builder agent on any page from a brief, auto-publishes (build-gated scoped commit), and self-logs to the changelog. Fed by the admin and by QA autofix.",
triggers: [{ type: "endpoint", method: "POST", path: "/tasks/run" }],
tools: "Read Write Edit Glob Grep WebSearch Skill",
skills: ["brand", "ui-ux", "changelog"],
tables: ["cja_tasks", "cja_changelog", "cja_projects"],
cli: ["tasks-next", "tasks-finish", "seed-changelog"],
};
let H;
let draining = false;
@ -146,7 +156,7 @@ async function drainTasks() {
}
}
export function register(host) {
export function register(host, settings = {}) {
H = host;
host.drainTasks = drainTasks; // QA autofix kicks the queue through this

View file

@ -2,27 +2,32 @@
/**
* AstroAgent console runner bootstrap.
*
* Loads the generic host, registers the agent modules, and starts listening.
* Runs AS the confined agent user (systemd User=), binds 127.0.0.1 only, and is
* reached exclusively through nginx, which gates every request with auth_request
* against the PHP admin session this process does not re-check that.
* Loads the generic host, then registers ONLY the agents this project enables
* (astroagent.config.json agents.enabled, exposed via site.agents), passing
* each its per-agent settings. Each capability is a module under ./agents/
* exporting register(host, settings).
*
* The shared runtime lives in ./host.mjs; each capability is a module under
* ./agents/ exporting register(host). (Phase 3 will drive this roster from
* astroagent.config.json instead of the hardcoded list below.)
*
* Node built-ins only no dependencies.
* Runs AS the confined agent user, binds 127.0.0.1, reached only through nginx
* (auth_request against the PHP admin session). Node built-ins only.
*/
import { host } from "./host.mjs";
import { register as registerInPageConsole } from "./agents/in-page-console.mjs";
import { register as registerProjectBuilder } from "./agents/project-builder.mjs";
import { register as registerWebDesigner } from "./agents/web-designer.mjs";
import { register as registerQa } from "./agents/qa.mjs";
import { site } from "./site.mjs";
registerInPageConsole(host);
registerProjectBuilder(host);
registerWebDesigner(host);
registerQa(host);
const { enabled, settings } = site.agents;
for (const name of enabled) {
try {
const mod = await import(`./agents/${name}.mjs`);
if (typeof mod.register === "function") {
mod.register(host, settings[name] || {});
console.log(`[console] agent enabled: ${name}`);
} else {
console.warn(`[console] agent '${name}' has no register() — skipped`);
}
} catch (err) {
console.error(`[console] agent '${name}' failed to load: ${err.message}`);
}
}
host.listen();

View file

@ -35,9 +35,15 @@ export const site = {
audience: sc.audience || "",
qaEmail: `qa@${host}`,
// Static route seed for the QA crawler (it also discovers via links + sitemap
// + qa-routes.php). Site-derived; Phase 3 moves this to agents.settings.qa.
qaRoutes: [
// + qa-routes.php). Site-derived; overridable via agents.settings.qa.routes.
qaRoutes: aa.agents?.settings?.qa?.routes || [
"/", "/about", "/services", "/services/website-design",
"/projects", "/blog", "/contact", "/changelog", "/resume", "/faq",
],
// Per-project agent roster: which console agents run here + their settings.
// Defaults to all four (back-compat) when astroagent.config.json omits it.
agents: {
enabled: aa.agents?.enabled || ["in-page-console", "project-builder", "web-designer", "qa"],
settings: aa.agents?.settings || {},
},
};