framework(core): lift site identity into site.mjs (config-driven)

Phase 2. host + agent modules no longer hardcode identity:
- site.mjs derives name/url/host/model/agentUser/home/role/qaEmail from
  site.config.json + astroagent.config.json
- host uses site.model/site.tools/site.home; QA uses site.url/host/qaEmail/routes
- personas templated from site.*; site-specific brand wording (sumi-e, .ca-*,
  'law firms') dropped — brand now comes only from brand/BRAND.md + the brand skill
Core (host.mjs + agents/*) is now site-agnostic; identity lives in config.
This commit is contained in:
Carlos Arias 2026-07-24 07:57:54 -04:00
parent 542858b1ec
commit 7799fd62e3
6 changed files with 72 additions and 25 deletions

View file

@ -5,15 +5,16 @@
* revert. Owns: /run, /stream (SSE), /publish, /rebuild, /discard.
*/
import { site } from "../site.mjs";
let H; // the host, set in register()
function buildPrompt({ message, page, selections }) {
const parts = [];
parts.push(
"You are the editing agent for the Carlos Arias website (an Astro + Tailwind v4 static site).",
`You are the editing agent for the ${site.name} website (an Astro + Tailwind v4 static site).`,
"Make ONLY the change the operator asks for. Keep everything on-brand:",
"the brand guide is brand/BRAND.md and the design tokens are in app/src/styles.css",
"(sumi-e — ink on washi paper, one vermillion seal, restraint, near-square radii).",
"the brand guide is brand/BRAND.md and the design tokens are in app/src/styles.css — read them and match the existing components.",
"Do NOT run builds or git commands — the console builds and publishes for you.",
"",
);

View file

@ -4,6 +4,8 @@
* it via the PHP API); never mutates the repo. Owns: /draft, /build-project.
*/
import { site } from "../site.mjs";
let H;
const MEDIA_RE = /^\/media\/[\w.-]+$/;
@ -17,7 +19,7 @@ function stripFence(t) {
// Turn a rough draft + prompt into polished project copy. No tools.
async function draftCopy({ name, category, draft, prompt }) {
const ask = [
"You are writing copy for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.",
`You are writing copy for ${site.name}'s portfolio (${site.host})${site.author.role ? " — " + site.author.role : ""}.`,
"Voice: precise, understated, confident. No hype, no buzzwords, no exclamation marks. Write a project case study.",
"",
`Project name: ${name}`,
@ -48,10 +50,10 @@ async function buildProject({ name, category, draft, prompt, images, instagram }
const imageLines = paths.length ? paths.map((p, i) => ` ${i + 1}. ${p}`).join("\n") : "";
const ask = [
"You are the Web Designer for Carlos Arias's portfolio (carlosarias.co) — an Agentic AI & Automation Engineer who builds for law firms and service businesses.",
`You are the Web Designer for ${site.name}'s portfolio (${site.host})${site.author.role ? " — " + site.author.role : ""}.`,
"Your job: turn the brief below into a COMPLETE project case study for a fixed, on-brand page template. You author STRUCTURED CONTENT, not HTML or layout.",
"",
"Brand (from brand/BRAND.md — honor it): sumi-e, ink on washi paper, a single vermillion seal, restraint. Voice is precise, understated, confident — no hype, no buzzwords, no exclamation marks. Never describe colours or layout; the template owns all styling.",
"Brand: honor the brand guide in brand/BRAND.md. Voice is precise, understated, confident — no hype, no buzzwords, no exclamation marks. Never describe colours or layout; the template owns all styling.",
"",
"The page auto-renders these sections from the fields you return — populate the ones the brief supports, leave the rest empty:",
"- header: title + one-line lede",
@ -81,7 +83,7 @@ async function buildProject({ name, category, draft, prompt, images, instagram }
'- "body": the Markdown case study.',
'- "kind": short project type, e.g. "Website" or "SaaS / Media" (or "").',
'- "period": a timeline label, e.g. "2024 — present" (or "").',
'- "role": Carlos\'s role on the project (or "").',
`- "role": ${site.author.name}'s role on the project (or "").`,
'- "categories": array of 1-3 short strings.',
'- "stack": array of technology strings.',
'- "skills": array of {"group": string, "items": [string, ...]}.',

View file

@ -7,15 +7,13 @@
*/
import { writeFileSync, rmSync } from "node:fs";
import { site } from "../site.mjs";
let H;
const QA_BASE = "https://carlosarias.co";
const QA_UA = "Mozilla/5.0 (compatible; CarlosAriasQA/1.0; +https://carlosarias.co)";
const QA_STATIC_ROUTES = [
"/", "/about", "/services", "/services/website-design",
"/projects", "/blog", "/contact", "/changelog", "/resume", "/faq",
];
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);
let qaRunning = false;
@ -91,7 +89,7 @@ async function runQa() {
if (!title) add("seo", "warning", path, "Missing <title>.", `${path} has no <title>. Add a page-specific title via its BaseLayout props.`);
else { if (!titles.has(title)) titles.set(title, []); titles.get(title).push(path); }
if (!desc) add("seo", "warning", path, "Missing meta description.", `${path} has no meta description. Add a page-specific description via its BaseLayout props.`);
if (canon) { try { const h = new URL(canon).host; if (h && h !== "carlosarias.co") add("seo", "error", path, `Canonical points to ${h}.`, `${path} canonical points to ${h} instead of carlosarias.co. Fix the site URL / canonical.`); } catch {} }
if (canon) { try { const h = new URL(canon).host; if (h && h !== site.host) add("seo", "error", path, `Canonical points to ${h}.`, `${path} canonical points to ${h} instead of ${site.host}. Fix the site URL / canonical.`); } catch {} }
if (!/<html[^>]+lang=/i.test(html)) add("a11y", "warning", path, "<html> has no lang attribute.", `${path} <html> tag has no lang attribute. Add lang="en".`);
for (const href of qaGrabAll(/<a\b[^>]*\bhref=["']([^"']+)["']/gi, html)) {
@ -139,13 +137,13 @@ async function runQa() {
const cHeaders = { "content-type": "application/json", origin: QA_BASE, referer: QA_BASE + "/contact" };
const hp = await probe(QA_BASE + "/api/contact/submit", {
method: "POST", readBody: true, headers: cHeaders,
body: JSON.stringify({ name: "QA Bot", email: "qa@carlosarias.co", subject: "other", message: "QA honeypot probe — please ignore.", company: "qa-honeypot" }),
body: JSON.stringify({ name: "QA Bot", email: site.qaEmail, subject: "other", message: "QA honeypot probe — please ignore.", company: "qa-honeypot" }),
});
if (hp.status !== 200) add("form", "error", "/api/contact/submit", `Contact honeypot probe returned ${hp.status || hp.error} (expected 200).`,
`POST /api/contact/submit returned ${hp.status || hp.error} instead of 200 for a probe. The contact form endpoint may be broken — check api/public/controllers/contact.php.`);
const val = await probe(QA_BASE + "/api/contact/submit", {
method: "POST", readBody: true, headers: cHeaders,
body: JSON.stringify({ name: "QA", email: "qa@carlosarias.co", subject: "other", message: "hi" }),
body: JSON.stringify({ name: "QA", email: site.qaEmail, subject: "other", message: "hi" }),
});
if (val.status !== 422) add("form", "warning", "/api/contact/submit", `Validation probe returned ${val.status || val.error} (expected 422 for a too-short message).`,
`POST /api/contact/submit did not reject an invalid submission (got ${val.status || val.error}, expected 422). Server-side validation may be off.`);
@ -177,7 +175,7 @@ async function qaTriage(findings, counts) {
const lines = top.map((f) => `- [${f.severity}] ${f.check_type} ${f.url}: ${f.detail}`).join("\n") || "(no issues found)";
const fallback = `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`;
const ask = [
"You are the QA agent for carlosarias.co. A crawler just tested the live site. Consult your `qa` skill.",
`You are the QA agent for ${site.host}. A crawler just tested the live site. Consult your qa skill.`,
`Counts: ${counts.error} errors, ${counts.warning} warnings across ${counts.pages} pages.`,
"Top findings:", lines,
"",

View file

@ -5,6 +5,8 @@
* reverts itself. Owns: /tasks/run. Exposes host.drainTasks for QA autofix.
*/
import { site } from "../site.mjs";
let H;
let draining = false;
@ -39,18 +41,18 @@ function buildDesignPrompt(task) {
const p = [];
p.push(
"You are the Web Designer for the Carlos Arias website (carlosarias.co) — an Astro + Tailwind v4 static site.",
`You are the Web Designer for the ${site.name} website (${site.host}) — an Astro + Tailwind v4 static site.`,
"You are a real designer: make considered, on-brand design decisions, not just literal edits.",
"",
"Before you start, consult your design skills and APPLY them: read .claude/skills/brand/SKILL.md and .claude/skills/ui-ux/SKILL.md.",
"The brand is sumi-e — ink on washi paper, a single vermillion seal, restraint, near-square 2px radii. Reuse the existing .ca-* classes and --ca-* tokens in app/src/styles.css and match nearby components; do not invent new one-off styles or add a second accent colour.",
"Before you start, consult your design skills and APPLY them: read .claude/skills/brand/SKILL.md and .claude/skills/ui-ux/SKILL.md, plus the brand guide in brand/BRAND.md.",
"Reuse the existing utility classes and CSS tokens in app/src/styles.css and match nearby components; do not invent new one-off styles or add a second accent colour.",
"Do NOT run builds or git — the console builds and publishes for you.",
"",
);
if (isNew) {
p.push(
`TASK: create a NEW page at /${slug}.`,
`- Create app/src/pages/${slug}.astro using BaseLayout and existing .ca-* section patterns (study app/src/pages/about.astro and services.astro for structure).`,
`- Create app/src/pages/${slug}.astro using BaseLayout and the existing section patterns (study an existing page under app/src/pages, e.g. about.astro, for structure).`,
"- Register it in the nav: add it to the nav array in app/src/components/Header.astro AND the footer links in app/src/components/Footer.astro.",
"",
);

View file

@ -16,6 +16,7 @@ import { randomUUID } from "node:crypto";
import { readFileSync, writeFileSync, existsSync, rmSync, mkdirSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { site } from "./site.mjs";
const HERE = dirname(fileURLToPath(import.meta.url)); // agents/console
const REPO = resolve(HERE, "..", ".."); // repo root
@ -25,9 +26,9 @@ const ENV_FILE = join(REPO, "agents", ".env");
const CLAUDE = "/usr/local/bin/claude";
const PHP = "/usr/bin/php";
const PORT = Number(process.env.ADMIN_PORT || 3011);
const AGENT_TOOLS = "Read Write Edit Glob Grep WebSearch";
const DEFAULT_MODEL = "claude-sonnet-4-6";
const GIT_SCOPE = ["app", "brand", "api/db", "api/cli"]; // paths the console may commit
const AGENT_TOOLS = site.tools; // from astroagent.config.json ai.tools
const DEFAULT_MODEL = site.model; // from astroagent.config.json ai.model
const GIT_SCOPE = ["app", "brand", "api/db", "api/cli"]; // SeedProject-conventional commit scope
// ---- env (.env) --------------------------------------------------------------
function loadEnv() {
@ -53,7 +54,7 @@ function agentEnv() {
const env = loadEnv();
return {
...process.env,
HOME: process.env.HOME || "/var/lib/carlos-arias-agent",
HOME: process.env.HOME || site.home,
PATH: "/usr/local/bin:/usr/bin:/bin",
CLAUDE_CODE_OAUTH_TOKEN: env.CLAUDE_CODE_OAUTH_TOKEN || "",
};

43
agents/console/site.mjs Normal file
View file

@ -0,0 +1,43 @@
/**
* Site identity for the AstroAgent runtime the ONLY place the framework reads
* who this deployment is. Everything site-specific (name, url, model, agent user,
* QA target) is derived here from site.config.json + astroagent.config.json, so
* host.mjs and the agent modules stay site-agnostic. Brand *rules* live in the
* per-site brand guide (brand/BRAND.md) and the `brand` skill, not here.
*/
import { readFileSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
const REPO = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
const readJson = (p) => { try { return JSON.parse(readFileSync(join(REPO, p), "utf8")); } catch { return {}; } };
const sc = readJson("site.config.json");
const aa = readJson("astroagent.config.json");
const url = String(sc.url || aa.url || "https://example.com").replace(/\/+$/, "");
let host = url; try { host = new URL(url).host; } catch {}
const agentUser = aa.ai?.agentUser || "astroagent";
export const site = {
name: sc.name || "the site",
url,
host,
model: aa.ai?.model || "claude-sonnet-4-6",
tools: aa.ai?.tools || "Read Write Edit Glob Grep WebSearch",
agentUser,
home: `/var/lib/${agentUser}`,
author: {
name: sc.author?.name || sc.name || "the author",
role: sc.author?.jobTitle || "",
},
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: [
"/", "/about", "/services", "/services/website-design",
"/projects", "/blog", "/contact", "/changelog", "/resume", "/faq",
],
};