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:
parent
7799fd62e3
commit
f287f74448
9 changed files with 243 additions and 32 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) ----
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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`);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 || {},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in a new issue