Compare commits

...

10 commits

Author SHA1 Message Date
6fec3498f2 framework(core): clone tooling + docs (Phase 4)
- FRAMEWORK.md: the host + pluggable-agent contract, the roster, and the
  clone+assign flow
- configure.mjs: ensure a default agents roster on a fresh clone
- .memory/{qa-agent,webdesigner}.md: framework notes pointing at the module layout
Verified: a scratch clone with a different site.config.json yields a different
identity + roster from identical core code.
2026-07-24 08:05:38 -04:00
25f281f659 site: enable in-page-console, project-builder, web-designer, qa (qa heartbeat 60) 2026-07-24 08:03:00 -04:00
f287f74448 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.
2026-07-24 08:03:00 -04:00
7799fd62e3 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.
2026-07-24 07:57:54 -04:00
542858b1ec framework(core): split console runner into host + pluggable agent modules
Phase 1 of the standalone agent framework. Pure refactor, behaviour identical:
- host.mjs: shared runtime (route registry, one runClaudeJson/runClaudeStream
  replacing 5 inline spawns, build/git/phpCli, SSE job model, scheduler, core
  /ping /auth /logout)
- agents/{in-page-console,project-builder,web-designer,qa}.mjs: each exports
  register(host), owns its routes/triggers/persona
- server.mjs: bootstrap that loads host + registers agents
- web-designer exposes host.drainTasks; qa autofix kicks it
Verified: QA pass, Web Designer queue task, heartbeat, all endpoints.
2026-07-24 07:50:26 -04:00
73403f7f21 Revert "web designer: Phase1 refactor test"
This reverts commit e5d68090e7.
2026-07-24 07:50:18 -04:00
e5d68090e7 web designer: Phase1 refactor test
[published via task queue]
2026-07-24 07:49:40 -04:00
74c6445e0e docs(.memory): QA agent reference + Web Designer self-logging/QA-loop update 2026-07-24 07:12:29 -04:00
57dee86981 web designer: QA fix: a11y — /blog
[published via task queue]
2026-07-24 10:41:41 +00:00
19c0cbb232 qa: auto-fix safe findings + per-finding instructions box
- qa-autofix.php queues Web Designer tasks for safe findings (alt, internal
  links) with 6h dedup; runQaFlow runs it after each pass and kicks the queue
  (QA_AUTOFIX env, on by default)
- adminqa fix() accepts optional human instructions; /admin/qa adds an
  instructions input per finding for the judgment calls
2026-07-24 10:39:15 +00:00
20 changed files with 1569 additions and 1027 deletions

138
.memory/qa-agent.md Normal file
View file

@ -0,0 +1,138 @@
# The QA agent — automated site testing with a self-healing loop
**Status:** Implemented · **Date:** 2026-07-24
**Related:** [webdesigner.md](webdesigner.md) · [../agents/console/FRAMEWORK.md](../agents/console/FRAMEWORK.md) · [../.claude/skills/qa/SKILL.md](../.claude/skills/qa/SKILL.md) · [../agents/console/SETUP.md](../agents/console/SETUP.md)
> **Framework note (2026-07-24):** the console runner was refactored into a generic host +
> pluggable agent modules. QA now lives in `agents/console/agents/qa.mjs` (exports `manifest`
> + `register(host, settings)`), is enabled via the `astroagent.config.json` roster, and reads
> `heartbeatMin`/`autofix` from `agents.settings.qa`. Its logic is unchanged from below; see
> [FRAMEWORK.md](../agents/console/FRAMEWORK.md).
---
## What it is
The QA agent tests the live site (carlosarias.co) — links, images, the contact form, the
API, SEO/meta, and accessibility — records findings in the admin, and **auto-fixes the safe
ones** by feeding the [Web Designer](webdesigner.md) queue. It runs on a **heartbeat** (hourly)
and on demand from `/admin/qa`.
**The honest architecture.** The confined agent (`claude -p`, no Bash/WebFetch) *cannot make
HTTP requests or drive a browser*, and no headless browser is usable by the runner. Since the
site is **static** (Astro → baked HTML), that's fine: a **deterministic Node crawler inside
the console runner** does the actual testing (Node 22 global `fetch`, zero deps), findings are
stored via **PHP CLI + DB** (the runner has no DB driver), and a **thin LLM step** writes a
plain-English summary using the `qa` skill. The crawl is the source of truth; the LLM only
triages.
## The closed loop
```
heartbeat (hourly) ──► runQa (crawl) ──► cja_qa_findings
├─ safe findings (alt, internal links)
│ └─ qa-autofix.php → cja_tasks (queued)
│ └─ Web Designer fixes → publishes
│ └─ self-logs to /changelog
│ └─ next QA run: green
└─ judgment calls (external links, etc.)
└─ shown in /admin/qa with an
instructions box → you dispatch a fix
```
The site went 14 warnings → 0 through this loop (sitemap, résumé portrait alt, blog cover alt
auto-fixed; GitHub link removed by hand).
## What the crawler checks
| check_type | Verifies | Severity |
|---|---|---|
| `page` | every route returns 200 + HTML | error |
| `link` | every internal `<a href>` resolves | error |
| `image` | every `<img>`/og:image/icon loads | error |
| `external` | off-site links reachable | warning (bot-blocking is common) |
| `form` | `/api/contact/submit` alive (honeypot probe → 200) + rejects bad input (→ 422) | error/warning |
| `health` | `/api/health` → 200 & `db:"connected"` (read at `data.db` — API wraps in `{ok,data,error}`) | error |
| `seo` | each page has `<title>` + description; canonical host = carlosarias.co; no duplicate titles | warning (error on wrong canonical) |
| `sitemap` | every route is in `sitemap.xml` | warning |
| `a11y` | images have alt — **empty/bare `alt` is flagged too** (valid only for decorative images) | warning |
Route set = static list + DB slugs (`qa-routes.php`) + sitemap + discovered internal links.
HTML parsed with regex (clean static HTML). Each finding carries a templated `fix_hint`.
## Auto-fix (safe findings)
- Runs after every QA pass when `QA_AUTOFIX``0` (default **on**). `qa-autofix.php` queues a
Web Designer `cja_tasks` row for each **open** finding in a **safe category**
`['a11y', 'link']` (see `SAFE` in that file) — then the runner kicks `drainTasks()`.
- **Dedup:** skips a finding if a same-title task is queued/running *or was created in the last
6 hours* — so hourly heartbeats never spam or loop on the same issue.
- Judgment calls (`external`, `image`, `seo`, `sitemap`, `form`, `health`, `page`) are **not**
auto-fixed — they wait in `/admin/qa` for a human decision.
## Report + instructions (the human path)
`/admin/qa` lists runs (counts, summary) and the latest run's findings grouped by severity.
Each open finding has:
- **Create fix task** → queues a Web Designer fix (uses the `fix_hint`).
- an **instructions input** → type how to handle it ("remove it", "use https://…", "reword")
and that becomes the Web Designer's brief instead of the default hint. This is where
decisions like a wrong external URL get made — in the admin, not in chat.
- **Ignore** → mark the finding handled.
## Heartbeat
In-process timer in `server.mjs` (the runner is always up, so no cron needed):
- `QA_HEARTBEAT_MIN` (default **60**, `0` disables) — set in the systemd unit.
- One pulse ~90s after each restart; then every N minutes.
- **Triage is gated** to save tokens: a scheduled run with 0 errors gets a cheap templated
summary (no LLM call); only manual runs or runs with errors get an LLM summary.
## Storage & pruning
- `cja_qa_runs` (status, trigger manual/scheduled, summary, counts JSON, timestamps)
- `cja_qa_findings` (run_id, check_type, severity, url, detail, fix_hint, status
open/fix_queued/ignored) — migration `api/db/migrations/013_create_cja_qa.sql`.
- `qa-finish.php` prunes to the **50 most recent runs** (hourly runs accumulate).
## Files
| File | Role |
|---|---|
| `agents/console/server.mjs` | `runQa`, `qaTriage`, `runQaFlow`, `POST /devconsole/qa/run`, heartbeat, `QA_AUTOFIX`/`QA_HEARTBEAT_MIN` |
| `api/db/migrations/013_create_cja_qa.sql` | runs + findings tables |
| `api/cli/qa-start.php` | open a run → run_id |
| `api/cli/qa-finish.php` | store findings (temp JSON file) + prune |
| `api/cli/qa-routes.php` | DB-driven slugs for the crawl |
| `api/cli/qa-autofix.php` | queue Web Designer fixes for safe findings (6h dedup) |
| `api/public/controllers/adminqa.php` | runs / findings / fix (optional instructions) / ignore |
| `app/src/pages/admin/qa.astro` | report page + instructions box + "Run QA now" |
| `.claude/skills/qa/SKILL.md` | what QA checks, severity model, gotchas |
## Operating it
- **Run now:** the "Run QA now" button, or `curl -s -X POST http://127.0.0.1:3011/devconsole/qa/run`.
- **Change cadence / disable heartbeat:** `QA_HEARTBEAT_MIN` in the unit (`0` = off), then
`systemctl daemon-reload && systemctl restart astroagent-console.service`.
- **Disable auto-fix:** `QA_AUTOFIX=0` in the unit env.
- **View:** `/admin/qa`.
## Safety & gotchas
- **Read-only against the live site** — no git, no build, no writes. The two contact-form
probes write nothing (the honeypot `company` field is filled on purpose; the validation
probe is rejected with 422). Verified: a full run leaves `cja_contact` untouched.
- **Fixes only ever go through the Web Designer queue** — build-gated, scoped commit,
git-revertable, self-logged. Auto-fix ≠ unsafe; a bad fix is one `git revert` away.
- **External links = warnings, never errors** (403/429/timeouts are usually bot-blocking; only
a clear 404/410/DNS failure is flagged).
- **Empty alt IS a finding.** `alt=""`/bare `alt` is valid only for *decorative* images; a
content image (photo, cover, screenshot) needs descriptive alt. (An earlier version wrongly
treated empty alt as a pass — corrected.)
- **Health check reads `data.db`** — the API wraps every response in `{ok, data, error}`.
- **Site-coupling:** `QA_BASE`, the static route list, and `carlosarias.co` are hard-coded in
`runQa` — same site-agnosticism gap noted for the Web Designer; parameterise from config for
a clean clone.
- **Timezone:** the server (and thus the runner's timestamps) is `America/New_York` as of
2026-07-24 (was UTC). New Web Designer changelog stamps are Eastern.

View file

@ -1,7 +1,7 @@
# The Web Designer — a core Astroagent console update
**Status:** Implemented · **Date:** 2026-07-24
**Related:** [../agents/console/PLAN.md](../agents/console/PLAN.md) · [../agents/console/SETUP.md](../agents/console/SETUP.md) · [../brand/BRAND.md](../brand/BRAND.md) · [../.claude/skills/brand/SKILL.md](../.claude/skills/brand/SKILL.md)
**Related:** [qa-agent.md](qa-agent.md) · [../agents/console/PLAN.md](../agents/console/PLAN.md) · [../agents/console/SETUP.md](../agents/console/SETUP.md) · [../brand/BRAND.md](../brand/BRAND.md) · [../.claude/skills/brand/SKILL.md](../.claude/skills/brand/SKILL.md) · [../.claude/skills/changelog/SKILL.md](../.claude/skills/changelog/SKILL.md)
---
@ -28,6 +28,32 @@ It coexists with two sibling surfaces built earlier in the same arc:
The Web Designer is the *freeform, queued* path for everything else.
> **Framework note (2026-07-24):** the console runner was refactored into a generic host +
> pluggable agent modules ([FRAMEWORK.md](../agents/console/FRAMEWORK.md)). The Web Designer now
> lives in `agents/console/agents/web-designer.mjs` (exports `manifest` + `register`), is enabled
> via the `astroagent.config.json` roster, and exposes `host.drainTasks` (QA autofix kicks it).
> Logic unchanged from below.
## Added after first write (2026-07-24)
Two capabilities were added to the Web Designer after this doc's first version:
- **Self-logs every change to the changelog.** `buildDesignPrompt` instructs the agent, after
finishing, to add one entry to `api/cli/seed-changelog.php` via the `changelog` skill,
attributed to **`Website Designer Agent`**, with the current timestamp (injected by the
runner via `nowStamp()` — a headless agent can't read the clock). Because the confined agent
has no shell, **the runner reseeds** `cja_changelog` (`php api/cli/seed-changelog.php`) when
that file changed, before the build, so the entry goes live. Changelog entries carry a
`by`/`actor` field (migration 012): agents name themselves; human/CLI edits default to
`Carlos Arias`.
- **Fed by the QA agent.** QA findings become Web Designer tasks — auto-queued for safe
categories, or dispatched from the `/admin/qa` instructions box. A QA-origin task is just a
normal `cja_tasks` row, so it fixes, publishes, and self-logs like any other. See
[qa-agent.md](qa-agent.md) for the closed loop.
**Timezone note:** `nowStamp()` uses the runner's system timezone, set to `America/New_York`
on 2026-07-24 (was UTC, which mis-stamped early agent entries).
## Architecture
```

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

@ -0,0 +1,82 @@
# AstroAgent — the standalone agent framework
A generic **host** that runs **pluggable agents**, cloneable into any SeedProject site, where
each project **enables the agents it needs** via config. The host knows nothing site-specific;
identity comes from config, brand from the per-site `brand` skill.
## Module layout (`agents/console/`)
| File | Role |
|---|---|
| `host.mjs` | The generic runtime — **site-agnostic**. Shared primitives + route registry + scheduler + core routes (`/ping`, `/auth`, `/logout`). |
| `site.mjs` | The **only** place identity is read. Derives `name / url / host / model / agentUser / home / role / qaEmail / qaRoutes / agents` from `site.config.json` + `astroagent.config.json`. |
| `server.mjs` | Bootstrap: load host → register the **enabled** agents (with their settings) → `host.listen()`. |
| `agents/<name>.mjs` | One agent each — `in-page-console`, `project-builder`, `web-designer`, `qa`. Site-agnostic; identity via `site.*`, brand via `brand/BRAND.md` + the `brand` skill. |
## Agent module contract
Each `agents/<name>.mjs` exports a `manifest` and a `register`:
```js
export const manifest = {
name: "qa", title: "QA Agent", class: "runtime",
description: "…",
triggers: [ { type:"endpoint", method:"POST", path:"/qa/run" },
{ type:"schedule", settingKey:"heartbeatMin" } ],
tools: "Read Skill", skills: ["qa"],
tables: ["cja_qa_runs","cja_qa_findings"], // migrations this agent needs
cli: ["qa-start","qa-finish","qa-routes","qa-autofix"], // api/cli/*.php bridges it needs
};
export function register(host, settings = {}) {
host.route("POST", "/qa/run", (req, res) => { /* uses host.* */ });
if ((settings.heartbeatMin ?? 60) > 0) host.everyMinutes(settings.heartbeatMin, fn);
}
```
`register` receives the host + this agent's per-project `settings`. It wires routes/triggers
and uses **only** `host.*` primitives — never globals — so an agent is a self-contained unit.
## Host API (`host.*`)
- `route(method, path, handler)` — register an HTTP route (`method:"ANY"` matches any verb).
- `runClaudeJson({prompt, tools, model})``{ok, result}` — one-shot Claude.
- `runClaudeStream({prompt, tools, model, resume, onMessage, …})` — streaming Claude (SSE).
- `build(extraEnv)`, `git(args)`, `phpCli(args)` — build / git / the DB bridge.
- `everyMinutes(min, fn, {bootDelayMs})` — scheduled trigger (heartbeat).
- `newJob/emit/jobs` — SSE job model. `readBody/json` — HTTP helpers.
- `state.busy` — the shared single-flight lock (mutable across agent modules).
- `gitScope`, `REPO`, `APP`, `PREVIEW_DIR`, `DEFAULT_MODEL`, `AGENT_TOOLS` — framework constants.
- Extension points agents attach, e.g. `host.drainTasks` (web-designer) which QA autofix calls.
## The roster — assign agents to a project
`astroagent.config.json`:
```json
"agents": {
"enabled": ["in-page-console", "project-builder", "web-designer", "qa"],
"settings": { "qa": { "heartbeatMin": 60, "autofix": true } }
}
```
`server.mjs` loads only `enabled` agents. Disable one → its routes 404 and its triggers stop.
A restaurant site might enable `["web-designer","qa","menu-updater"]`; a law firm
`["web-designer","qa","seo","intake-triage"]` — same framework, different roster.
## Clone + assign
1. Clone the base → `scripts/new-site.sh` (or edit `site.config.json` + `node scripts/configure.mjs`) — stamps identity (name, url, agentUser, timezone…). `site.mjs` picks these up automatically.
2. Set `astroagent.config.json → agents.enabled` to the roster this project needs; tune `settings`.
3. Provision each enabled agent's dependencies — its `tables` (migrations), its `cli` (`api/cli/*.php`), its `skills` (`.claude/skills/*` or MCP). The manifest lists them; **`node agents/catalog.mjs``agents/agents.json`, diff it against a clone to find gaps.**
4. Start the console service → the host loads exactly the enabled agents.
## Add a new agent
Drop `agents/console/agents/<name>.mjs` exporting `manifest` + `register`, add its name to
`agents.enabled`, provide its migrations/CLI/skills, run `node agents/catalog.mjs`, restart.
## What stays site-specific (never in the core)
`site.config.json` + `astroagent.config.json` (identity + roster), the `brand` skill +
`brand/BRAND.md` (visual rules), the `cja_*` content, and the built site. The registry
(`agents/agents.json`, generated) unifies pipeline (`agents/scripts/*`) and runtime (console)
agents so one manifest diff covers a whole clone.

View file

@ -0,0 +1,190 @@
/**
* In-page console agent the freeform editor. The operator describes a change
* on any page; the agent (Read/Write/Edit/Glob/Grep/WebSearch, no Bash) edits the
* working tree; the host builds an isolated preview; publish/discard promote or
* revert. Owns: /run, /stream (SSE), /publish, /rebuild, /discard.
*/
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 }) {
const parts = [];
parts.push(
`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 — read them and match the existing components.",
"Do NOT run builds or git commands — the console builds and publishes for you.",
"",
);
if (page && page !== "/") {
parts.push(`The operator is on the page: ${page}`);
parts.push("Its source is almost certainly under app/src/pages (find it with Glob/Grep).", "");
} else if (page === "/") {
parts.push("The operator is on the homepage (app/src/pages/index.astro).", "");
}
if (Array.isArray(selections) && selections.length) {
parts.push("They selected these element(s) on the page:");
for (const s of selections) {
const tag = s.tag || s.selector || "element";
parts.push(`- <${tag}>${s.text ? ` — "${String(s.text).slice(0, 80)}"` : ""}${s.comment ? ` — note: ${s.comment}` : ""}`);
}
parts.push("");
}
parts.push("Request:", message);
return parts.join("\n");
}
function shortPath(p) {
if (!p) return "";
return String(p).replace(H.REPO + "/", "");
}
function handleStreamMsg(job, msg) {
if (msg.session_id) job.sessionId = msg.session_id;
const content = msg?.message?.content;
if (Array.isArray(content)) {
for (const block of content) {
if (block.type === "text" && block.text) H.emit(job, { kind: "text", text: block.text });
else if (block.type === "tool_use") {
const label = block.name === "Edit" || block.name === "Write"
? `${block.name} ${shortPath(block.input?.file_path)}`
: block.name;
H.emit(job, { kind: "tool", text: label });
}
}
}
if (msg.type === "result" && typeof msg.result === "string" && msg.result.trim()) {
H.emit(job, { kind: "text", text: msg.result.trim() });
}
}
async function buildPreview(job) {
H.emit(job, { kind: "tool", text: "building preview…" });
const out = H.join("..", "public-preview", job.jobId);
const base = `/_preview/${job.jobId}`;
const { ok } = await H.build({ PREVIEW_OUT: out, PREVIEW_BASE: base });
if (!ok) {
H.emit(job, { type: "error", text: "The change broke the build, so it was not applied. Try rephrasing." });
await H.git(["checkout", "--", ...H.gitScope]);
H.emit(job, { type: "done" });
job.done = true;
return;
}
const target = job.page && job.page !== "/" ? job.page.replace(/^\//, "") : "";
H.emit(job, { type: "preview", url: `${base}/${target}` });
H.emit(job, { type: "done" });
job.done = true;
}
function runAgent(job, { message, page, selections, model }) {
const prompt = buildPrompt({ message, page, selections });
H.runClaudeStream({
prompt, tools: H.AGENT_TOOLS, model, resume: job.sessionId,
onMessage: (msg) => handleStreamMsg(job, msg),
onStderr: (t) => { const s = t.trim(); if (s) H.emit(job, { kind: "tool", text: s.slice(0, 200) }); },
onClose: async (code) => {
if (code !== 0) {
H.emit(job, { type: "error", text: "The agent stopped unexpectedly. Nothing was changed." });
H.emit(job, { type: "done" });
job.done = true;
H.state.busy = false;
return;
}
await buildPreview(job);
H.state.busy = false;
},
onError: (err) => {
H.emit(job, { type: "error", text: `Could not start the agent: ${err.message}` });
H.emit(job, { type: "done" });
job.done = true;
H.state.busy = false;
},
});
}
export function register(host, settings = {}) {
H = host;
host.route("POST", "/run", async (req, res) => {
if (H.state.busy) return H.json(res, 429, { error: "A change is already in progress — let it finish." });
const body = await H.readBody(req);
const message = String(body.message || "").trim();
if (!message) return H.json(res, 400, { error: "Say what you'd like changed." });
const env = H.loadEnv();
if (!env.CLAUDE_CODE_OAUTH_TOKEN) return H.json(res, 401, { error: "No Claude token set. Add one with the 🔑 button." });
let job = body.conversationId && H.jobs.get(body.conversationId);
if (job) { job.done = false; job.events = []; job.jobId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`; }
else job = H.newJob(body.page);
H.state.busy = true;
runAgent(job, { message, page: body.page, selections: body.selections, model: body.model });
H.json(res, 200, { conversationId: job.conversationId });
});
host.route("ANY", "/stream", (req, res, { url }) => {
const id = url.searchParams.get("conversationId");
const job = id && H.jobs.get(id);
if (!job) return H.json(res, 404, { error: "unknown conversation" });
res.writeHead(200, { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" });
for (const ev of job.events) res.write(`data: ${JSON.stringify(ev)}\n\n`);
if (job.done) return res.end();
job.clients.add(res);
req.on("close", () => job.clients.delete(res));
});
host.route("POST", "/publish", async (req, res) => {
if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." });
const { conversationId } = await H.readBody(req);
const job = conversationId && H.jobs.get(conversationId);
if (!job) return H.json(res, 404, { error: "Nothing to publish." });
H.state.busy = true;
const built = await H.build({});
if (!built.ok) { H.state.busy = false; return H.json(res, 500, { error: "Build failed — not published." }); }
await H.git(["add", "--", ...H.gitScope]);
const summary = (job.page && job.page !== "/" ? job.page : "homepage");
await H.git(["commit", "-q", "-m", `console: edit ${summary}\n\n[published via astroagent console]`]);
H.rmSync(H.join(H.PREVIEW_DIR, job.jobId), { recursive: true, force: true });
H.state.busy = false;
H.json(res, 200, { ok: true, live: job.page || "/" });
});
// Rebuild the live site after DB-driven content edits (projects gallery, etc.).
host.route("POST", "/rebuild", async (req, res) => {
if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." });
H.state.busy = true;
const built = await H.build({});
if (!built.ok) { H.state.busy = false; return H.json(res, 500, { error: "Build failed." }); }
await H.git(["add", "--", "app/public/media", ...H.gitScope]);
await H.git(["commit", "-q", "-m", "console: content update"]); // ok if nothing to commit
H.state.busy = false;
H.json(res, 200, { ok: true });
});
host.route("POST", "/discard", async (req, res) => {
if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." });
const { conversationId } = await H.readBody(req);
const job = conversationId && H.jobs.get(conversationId);
H.state.busy = true;
await H.git(["checkout", "--", ...H.gitScope]);
await H.git(["clean", "-fd", ...H.gitScope]);
if (job) H.rmSync(H.join(H.PREVIEW_DIR, job.jobId), { recursive: true, force: true });
H.state.busy = false;
if (job) H.jobs.delete(job.conversationId);
H.json(res, 200, { ok: true });
});
}

View file

@ -0,0 +1,157 @@
/**
* Project builder one-shot copy drafting and full structured-project authoring
* for the admin's New Project form. Returns JSON to the admin UI (which persists
* it via the PHP API); never mutates the repo. Owns: /draft, /build-project.
*/
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.-]+$/;
const MAX_BUILD_IMAGES = 8;
function stripFence(t) {
const m = String(t).match(/```(?:json)?\s*([\s\S]*?)```/);
return (m ? m[1] : t).trim();
}
// 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 ${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}`,
category ? `Category: ${category}` : "",
draft ? `The author's rough draft / notes:\n${draft}` : "",
prompt ? `The author's instructions:\n${prompt}` : "",
"",
"Return ONLY a JSON object (no markdown fence, no commentary) with these string keys:",
'- "summary": one sentence (<=160 chars) for the projects list.',
'- "lede": one punchy opening line for the top of the project page.',
'- "body": the case study in Markdown. Use ## for section headings and - for bullets. Open with a short overview, and where it fits include a "## Highlights" bulleted section. 150-350 words.',
].filter(Boolean).join("\n");
const { ok, result } = await H.runClaudeJson({ prompt: ask, tools: "" });
if (!ok) return { ok: false };
try { const obj = JSON.parse(stripFence(result)); return { ok: true, summary: obj.summary || "", lede: obj.lede || "", body: obj.body || "" }; }
catch { return { ok: false }; }
}
// Read the uploaded images and author a COMPLETE cja_projects record. Read-only.
async function buildProject({ name, category, draft, prompt, images, instagram }) {
const srcs = (Array.isArray(images) ? images : [])
.map((im) => String(im?.src || ""))
.filter((s) => MEDIA_RE.test(s));
const useSrcs = srcs.slice(0, MAX_BUILD_IMAGES);
if (srcs.length > MAX_BUILD_IMAGES) console.log(`[build-project] capping images ${srcs.length} -> ${MAX_BUILD_IMAGES}`);
const paths = useSrcs.map((s) => "app/public" + s);
const imageLines = paths.length ? paths.map((p, i) => ` ${i + 1}. ${p}`).join("\n") : "";
const ask = [
`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: 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",
"- facts rail: kind (project type), period (timeline label), role",
"- metrics: a few outcome stats, each {value, label, note?}",
"- gallery: your caption for each image below",
"- body: the case study",
"- skills: grouped disciplines, each {group, items[]}",
"- stack: technologies used, as plain strings",
"- categories: 1-3 short tags",
"",
`Project name: ${name}`,
category ? `Category hint: ${category}` : "",
instagram ? "There is an Instagram reel for this project." : "",
draft ? `The author's rough draft / notes:\n${draft}` : "",
prompt ? `The author's special instructions (follow these):\n${prompt}` : "",
"",
paths.length
? `Uploaded images — READ each file and caption it from what is ACTUALLY shown. Keep captions short and specific; do not invent UI or content that isn't visible:\n${imageLines}`
: "No images were uploaded.",
"",
"Body rules: Markdown only, using ONLY ## / ### headings, paragraphs, - bullet lists, and **bold**. No images, no HTML, no tables. Open with a short overview; where it fits include a '## Highlights' bulleted section. 150-350 words.",
"",
"Return ONLY a JSON object (no markdown fence, no commentary) with these keys:",
'- "summary": one sentence (<=160 chars) for the projects list.',
'- "lede": one punchy opening line.',
'- "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": ${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, ...]}.',
'- "metrics": array of {"value": string, "label": string, "note"?: string}. Use an empty array if the brief has no real numbers — do NOT invent metrics.',
`- "captions": array of exactly ${paths.length} strings, one per uploaded image IN ORDER.`,
`- "alts": array of exactly ${paths.length} short alt-text strings, one per image IN ORDER.`,
].filter(Boolean).join("\n");
const { ok, result } = await H.runClaudeJson({ prompt: ask, tools: "Read" });
if (!ok) return { ok: false };
try {
const obj = JSON.parse(stripFence(result));
const caps = Array.isArray(obj.captions) ? obj.captions : [];
const alts = Array.isArray(obj.alts) ? obj.alts : [];
const gallery = useSrcs.map((src, i) => ({
src, alt: String(alts[i] || "").slice(0, 200), caption: String(caps[i] || "").slice(0, 120),
}));
const strArr = (a) => (Array.isArray(a) ? a.map((x) => String(x)).filter(Boolean) : []);
return {
ok: true,
summary: obj.summary || "", lede: obj.lede || "", body: obj.body || "",
kind: obj.kind || "", period: obj.period || "", role: obj.role || "",
categories: strArr(obj.categories), stack: strArr(obj.stack),
skills: Array.isArray(obj.skills) ? obj.skills : [],
metrics: Array.isArray(obj.metrics) ? obj.metrics : [],
gallery,
};
} catch { return { ok: false }; }
}
export function register(host, settings = {}) {
H = host;
host.route("POST", "/draft", async (req, res) => {
if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." });
const body = await H.readBody(req);
const name = String(body.name || "").trim();
if (!name) return H.json(res, 400, { error: "Add a project name first." });
const env = H.loadEnv();
if (!env.CLAUDE_CODE_OAUTH_TOKEN) return H.json(res, 401, { error: "No Claude token set." });
H.state.busy = true;
const d = await draftCopy(body);
H.state.busy = false;
if (!d.ok) return H.json(res, 500, { error: "Couldn't generate a draft — try again." });
H.json(res, 200, d);
});
host.route("POST", "/build-project", async (req, res) => {
if (H.state.busy) return H.json(res, 429, { error: "Busy — try again in a moment." });
const body = await H.readBody(req);
const name = String(body.name || "").trim();
if (!name) return H.json(res, 400, { error: "Add a project name first." });
const env = H.loadEnv();
if (!env.CLAUDE_CODE_OAUTH_TOKEN) return H.json(res, 401, { error: "No Claude token set." });
H.state.busy = true;
const d = await buildProject(body);
H.state.busy = false;
if (!d.ok) return H.json(res, 500, { error: "The Web Designer couldn't finish — try again." });
H.json(res, 200, d);
});
}

View file

@ -0,0 +1,248 @@
/**
* QA agent a deterministic HTTP crawler that tests the live static site (links,
* images, forms, API, SEO/meta, a11y), stores findings via the qa-*.php CLI, and
* a thin LLM step writes a summary. Read-only against the site. Auto-fixes safe
* findings by kicking the Web Designer queue (host.drainTasks). Owns: /qa/run +
* an in-process heartbeat.
*/
import { writeFileSync, rmSync } from "node:fs";
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_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 } = {}) {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeout);
try {
const r = await fetch(url, { method, redirect: "follow", signal: ctrl.signal, headers: { "user-agent": QA_UA, ...headers }, body });
let text = null;
if (readBody) text = await r.text();
else { try { await r.body?.cancel(); } catch {} }
return { status: r.status, ok: r.ok, finalUrl: r.url, text };
} catch (e) {
return { status: 0, ok: false, error: e.name === "AbortError" ? "timeout" : (e.message || "network error") };
} finally { clearTimeout(t); }
}
async function pMap(items, concurrency, fn) {
const out = []; let i = 0;
const workers = Array.from({ length: Math.min(concurrency, items.length || 1) }, async () => {
while (i < items.length) { const idx = i++; out[idx] = await fn(items[idx], idx); }
});
await Promise.all(workers);
return out;
}
const qaNorm = (p) => { p = String(p).split("#")[0].split("?")[0]; if (p.length > 1) p = p.replace(/\/+$/, ""); return p || "/"; };
const qaAbs = (href, pagePath) => { try { return new URL(href, QA_BASE + pagePath).href; } catch { return null; } };
const qaGrabAll = (re, html) => [...String(html).matchAll(re)].map((m) => m[1]);
const qaFirst = (re, html) => { const m = String(html).match(re); return m ? m[1].trim() : ""; };
function qaAltIssues(html) {
const out = [];
for (const m of String(html).matchAll(/<img\b[^>]*>/gi)) {
const tag = m[0];
const src = (tag.match(/\bsrc=["']([^"']+)["']/i) || [])[1];
if (!src) continue;
const withVal = tag.match(/\salt\s*=\s*["']([^"']*)["']/i);
if (withVal) { if (withVal[1].trim() === "") out.push({ src, kind: "empty" }); }
else if (/\salt(\s|>|\/)/i.test(tag)) out.push({ src, kind: "empty" });
else out.push({ src, kind: "missing" });
}
return out;
}
async function runQa() {
const findings = [];
const add = (check_type, severity, url, detail, fix_hint = null) => findings.push({ check_type, severity, url, detail, fix_hint });
let dynamic = [];
try { dynamic = JSON.parse((await H.phpCli(["api/cli/qa-routes.php"])).out || "[]"); } catch {}
const routes = [...new Set([...QA_STATIC_ROUTES, ...dynamic].map(qaNorm))];
const pages = await pMap(routes, 6, async (path) => ({ path, r: await probe(QA_BASE + path, { readBody: true }) }));
const fetched = new Map();
const titles = new Map();
const internal = new Set();
const images = new Set();
const external = new Map();
for (const { path, r } of pages) {
fetched.set(path, r.ok);
if (!r.ok) {
add("page", "error", path, `Returns ${r.status || r.error} instead of 200.`,
`${path} returns ${r.status || r.error} instead of 200. Investigate why the page fails to render and fix it.`);
continue;
}
const html = r.text || "";
const title = qaFirst(/<title[^>]*>([^<]*)<\/title>/i, html);
const desc = qaFirst(/<meta[^>]+name=["']description["'][^>]+content=["']([^"']*)["']/i, html);
const canon = qaFirst(/<link[^>]+rel=["']canonical["'][^>]+href=["']([^"']*)["']/i, html);
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 !== 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)) {
if (/^(mailto:|tel:|javascript:|#|data:)/i.test(href)) continue;
const u = qaAbs(href, path); if (!u) continue;
const noHash = u.split("#")[0];
if (noHash.startsWith(QA_BASE)) internal.add(noHash);
else if (/^https?:\/\//i.test(noHash) && !external.has(noHash)) external.set(noHash, path);
}
for (const src of qaGrabAll(/<img\b[^>]*\bsrc=["']([^"']+)["']/gi, html)) { const u = qaAbs(src, path); if (u && /^https?:/i.test(u)) images.add(u.split("#")[0]); }
for (const a of qaAltIssues(html)) {
if (a.kind === "empty") add("a11y", "warning", path, `Empty alt: ${a.src}`,
`On ${path}, the image "${a.src}" has an empty alt attribute. If it conveys meaning (a photo, cover, or screenshot), add descriptive alt text that explains what it shows; leave it empty only if it is purely decorative.`);
else add("a11y", "warning", path, `Missing alt: ${a.src}`,
`On ${path}, the image "${a.src}" has no alt attribute. Add descriptive alt text that explains what it shows.`);
}
}
for (const [title, paths] of titles) {
if (paths.length > 1) add("seo", "warning", paths.join(", "), `${paths.length} pages share the title "${title}".`,
`These pages share one <title> ("${title}"): ${paths.join(", ")}. Give each a distinct, page-specific title.`);
}
const internalPaths = [...new Set([...internal].map((u) => qaNorm(u.replace(QA_BASE, "") || "/")))];
const toCheck = internalPaths.filter((p) => !fetched.has(p));
await pMap(toCheck, 8, async (p) => {
const r = await probe(QA_BASE + p, {});
if (!r.ok) add("link", "error", p, `Broken internal link (${r.status || r.error}).`,
`An internal link points to ${p}, which returns ${r.status || r.error}. Find that link in the page source and fix the URL or remove the link.`);
});
await pMap([...images], 8, async (u) => {
const r = await probe(u, {});
if (!r.ok) add("image", "error", u, `Image returns ${r.status || r.error}.`,
`The image ${u} returns ${r.status || r.error}. Fix the image path or replace the image.`);
});
await pMap([...external.keys()], 6, async (u) => {
const r = await probe(u, { method: "GET", timeout: 12000 });
const clearlyBad = r.status === 404 || r.status === 410 || (r.status === 0 && r.error && r.error !== "timeout");
if (clearlyBad) add("external", "warning", u, `External link may be broken (${r.status || r.error}); found on ${external.get(u)}.`,
`The external link ${u} (on ${external.get(u)}) appears broken (${r.status || r.error}). Verify it and update or remove it.`);
});
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: 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: 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.`);
const health = await probe(QA_BASE + "/api/health", { readBody: true });
let dbOk = false; try { const j = JSON.parse(health.text || "{}"); dbOk = (j.data?.db ?? j.db) === "connected"; } catch {}
if (health.status !== 200 || !dbOk) add("health", "error", "/api/health", `Status ${health.status || health.error}, db ${dbOk ? "connected" : "not connected"}.`,
`/api/health returned ${health.status || health.error}${dbOk ? "" : " and the database is not connected"}. The API or database may be down.`);
const sm = await probe(QA_BASE + "/sitemap.xml", { readBody: true });
if (sm.ok) {
const locs = new Set(qaGrabAll(/<loc>([^<]+)<\/loc>/gi, sm.text || "").map((l) => qaNorm(l.replace(QA_BASE, ""))));
for (const p of routes) if (!locs.has(p)) add("sitemap", "warning", p, "Not listed in sitemap.xml.",
`${p} is not in sitemap.xml. Add it in app/src/pages/sitemap.xml.js so search engines can find it.`);
} else {
add("sitemap", "warning", "/sitemap.xml", `sitemap.xml returned ${sm.status || sm.error}.`, `/sitemap.xml is unreachable (${sm.status || sm.error}). Check app/src/pages/sitemap.xml.js.`);
}
const counts = { error: 0, warning: 0, info: 0, pages: pages.length, links: toCheck.length, images: images.size, external: external.size };
for (const f of findings) counts[f.severity] = (counts[f.severity] || 0) + 1;
return { findings, counts };
}
async function qaTriage(findings, counts) {
const top = [
...findings.filter((f) => f.severity === "error").slice(0, 12),
...findings.filter((f) => f.severity === "warning").slice(0, 12),
];
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 ${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,
"",
"Write a 2-4 sentence plain-English summary for the site owner: overall health, the most important things to fix first, and whether anything is urgent. No preamble — just the summary.",
].join("\n");
const { ok, result } = await H.runClaudeJson({ prompt: ask, tools: "Read Skill" });
const s = ok && typeof result === "string" ? result.trim().slice(0, 800) : "";
return s || fallback;
}
async function runQaFlow(trigger) {
if (qaRunning) return;
qaRunning = true;
let runId = 0;
try {
const start = await H.phpCli(["api/cli/qa-start.php", `--trigger=${trigger}`]);
runId = JSON.parse(start.out || "{}").run_id || 0;
if (!runId) throw new Error("could not open a QA run");
const { findings, counts } = await runQa();
const summary = (trigger === "manual" || counts.error > 0)
? await qaTriage(findings, counts)
: `${counts.error} error(s) and ${counts.warning} warning(s) across ${counts.pages} pages.`;
const tmp = `/tmp/qa-${runId}.json`;
writeFileSync(tmp, JSON.stringify(findings));
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 (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
}
} catch (e) {
if (runId) await H.phpCli(["api/cli/qa-finish.php", `--run=${runId}`, "--status=failed", `--summary=QA run failed: ${String((e && e.message) || e).slice(0, 180)}`]);
} finally {
qaRunning = false;
}
}
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." });
const body = await H.readBody(req);
const trigger = body.trigger === "scheduled" ? "scheduled" : "manual";
runQaFlow(trigger); // fire-and-forget
H.json(res, 200, { ok: true });
});
if (heartbeatMin > 0) {
host.everyMinutes(heartbeatMin, () => runQaFlow("scheduled"), { bootDelayMs: 90_000 });
console.log(`[qa] heartbeat every ${heartbeatMin} min`);
}
}

View file

@ -0,0 +1,169 @@
/**
* Web Designer agent a durable design queue (cja_tasks) drained one task at a
* time. Each task runs the full-builder agent on any page (skills + brief), then
* auto-publishes: build must pass, then a scoped git commit; a failing task
* reverts itself. Owns: /tasks/run. Exposes host.drainTasks for QA autofix.
*/
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;
async function claimNextTask() {
const { out } = await H.phpCli(["api/cli/tasks-next.php"]);
try { const t = JSON.parse((out || "{}").trim() || "{}"); return t && t.task_id ? t : null; }
catch { return null; }
}
async function finishTask(id, status, result) {
await H.phpCli(["api/cli/tasks-finish.php", `--id=${id}`, `--status=${status}`, `--result=${JSON.stringify(result)}`]);
}
function liveUrl(target) {
if (!target) return "/";
if (target.startsWith("new:")) return "/" + target.slice(4).replace(/^\/+/, "");
return target;
}
function nowStamp() {
const d = new Date();
const p = (n) => String(n).padStart(2, "0");
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
}
function buildDesignPrompt(task) {
let assets = { images: [], videos: [] };
try { assets = JSON.parse(task.assets || "{}") || {}; } catch {}
const images = Array.isArray(assets.images) ? assets.images : [];
const videos = Array.isArray(assets.videos) ? assets.videos : [];
const target = task.target_page || "/";
const isNew = target.startsWith("new:");
const slug = isNew ? target.slice(4).replace(/^\/+/, "") : "";
const p = [];
p.push(
`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, 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 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.",
"",
);
} else {
p.push(
`TASK: work on the existing page ${target}.`,
"- Find its source under app/src/pages (Glob/Grep). Edit that file and any components it uses.",
"",
);
}
p.push("What to do:", task.prompt || "(no instructions given)", "");
if (task.draft && String(task.draft).trim()) {
p.push("Draft content to work from (polish it, don't paste it verbatim):", task.draft, "");
}
if (images.length) {
p.push("Images you may use — Read each to see what it shows, then place it with its /media/... src and a real alt:");
for (const im of images) p.push(`- ${im.url} (on disk: app/public${im.url})${im.alt ? ` — hint: ${im.alt}` : ""}`);
p.push("");
}
if (videos.length) {
p.push("Short video links to embed where they fit (e.g. an Instagram reel — responsive 9:16, no autoplay sound):");
for (const v of videos) p.push(`- ${v}`);
p.push("");
}
p.push(
"",
"After you finish the change, log it to the public changelog using your `changelog` skill:",
"- Add ONE entry to the $entries array in api/cli/seed-changelog.php, in the site's visitor-facing voice.",
`- Use the timestamp '${nowStamp()}' and attribute it to 'Website Designer Agent' (the 5th array element).`,
"- Choose the right type (added / updated / fixed / removed).",
"- Do NOT run the reseed or the build — the console does that for you.",
"- If you ended up making no change to the site, do not add a changelog entry.",
"",
"Keep the change scoped to what's asked and leave the working tree with only your intended edits.",
);
return p.join("\n");
}
async function runDesignTask(task) {
const prompt = buildDesignPrompt(task);
// Pre-flight: a dirty tree would be swept into this task's scoped commit.
// Uploaded assets under app/public/media/ are this task's images and are fine.
const pre = await H.git(["status", "--porcelain", "--", ...H.gitScope]);
const dirty = pre.tail.split("\n").map((l) => l.slice(3).trim()).filter(Boolean).filter((p) => !p.startsWith("app/public/media/"));
if (dirty.length) return { ok: false, error: "Working tree wasn't clean — commit or discard pending changes before running the queue." };
const { result } = await H.runClaudeJson({ prompt, tools: "Read Write Edit Glob Grep WebSearch Skill" });
const summary = typeof result === "string" ? result.trim().slice(0, 500) : "";
const status = await H.git(["status", "--porcelain", "--", ...H.gitScope]);
if (status.tail.trim() === "") {
return { ok: true, commit: "", summary: summary || "No changes were needed.", live: liveUrl(task.target_page) };
}
// The agent logs to the changelog by editing seed-changelog.php; reseed so the
// build picks it up (the changelog page reads the DB at build time).
if (/seed-changelog\.php/.test(status.tail)) await H.phpCli(["api/cli/seed-changelog.php"]);
const built = await H.build({});
if (!built.ok) {
await H.git(["checkout", "--", ...H.gitScope]);
await H.git(["clean", "-fd", ...H.gitScope]);
return { ok: false, error: "The change broke the build, so it was reverted." };
}
await H.git(["add", "--", ...H.gitScope]);
const title = (task.title || "task").toString().slice(0, 120);
await H.git(["commit", "-q", "-m", `web designer: ${title}\n\n[published via task queue]`]);
const head = await H.git(["rev-parse", "--short", "HEAD"]);
return { ok: true, commit: head.tail.trim(), summary, live: liveUrl(task.target_page) };
}
// Drain the queue sequentially. Shares host.state.busy; re-entrant-safe.
async function drainTasks() {
if (draining) return;
draining = true;
try {
for (;;) {
if (H.state.busy) break; // in-page console mid-edit; a later kick resumes
const task = await claimNextTask();
if (!task) break;
H.state.busy = true;
let res;
try { res = await runDesignTask(task); }
catch (e) { res = { ok: false, error: String((e && e.message) || e) }; }
H.state.busy = false;
await finishTask(task.task_id, res.ok ? "published" : "failed",
res.ok ? { commit: res.commit, summary: res.summary, live: res.live } : { error: res.error || "failed" });
}
} finally {
draining = false;
}
}
export function register(host, settings = {}) {
H = host;
host.drainTasks = drainTasks; // QA autofix kicks the queue through this
host.route("POST", "/tasks/run", async (req, res) => {
const env = H.loadEnv();
if (!env.CLAUDE_CODE_OAUTH_TOKEN) return H.json(res, 401, { error: "No Claude token set." });
drainTasks(); // fire-and-forget
H.json(res, 200, { ok: true, draining, busy: H.state.busy });
});
}

226
agents/console/host.mjs Normal file
View file

@ -0,0 +1,226 @@
/**
* AstroAgent host the generic runtime that hosts pluggable agents.
*
* Provides the shared primitives every agent reuses (Claude spawn, build, git,
* the PHP-CLI DB bridge, SSE job model, a route registry, and a scheduler) and
* knows NOTHING agent-specific. Agent modules under ./agents/ call
* `host.route(...)` / `host.everyMinutes(...)` and use `host.runClaudeJson` etc.
*
* Reached only through nginx (auth_request against the PHP admin session); this
* process does not re-check auth. Node built-ins only no dependencies.
*/
import { createServer } from "node:http";
import { spawn } from "node:child_process";
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
const APP = join(REPO, "app");
const PREVIEW_DIR = join(REPO, "public-preview");
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 = 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() {
const out = {};
if (existsSync(ENV_FILE)) {
for (const line of readFileSync(ENV_FILE, "utf8").split("\n")) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m) out[m[1]] = m[2];
}
}
return out;
}
function saveToken(token) {
let s = existsSync(ENV_FILE) ? readFileSync(ENV_FILE, "utf8") : "";
if (/^CLAUDE_CODE_OAUTH_TOKEN=.*$/m.test(s)) {
s = s.replace(/^CLAUDE_CODE_OAUTH_TOKEN=.*$/m, `CLAUDE_CODE_OAUTH_TOKEN=${token}`);
} else {
s += `\nCLAUDE_CODE_OAUTH_TOKEN=${token}\n`;
}
writeFileSync(ENV_FILE, s, { mode: 0o600 });
}
function agentEnv() {
const env = loadEnv();
return {
...process.env,
HOME: process.env.HOME || site.home,
PATH: "/usr/local/bin:/usr/bin:/bin",
CLAUDE_CODE_OAUTH_TOKEN: env.CLAUDE_CODE_OAUTH_TOKEN || "",
};
}
// ---- Claude spawn (the ONE place agents invoke claude) -----------------------
/** One-shot: `claude -p … --output-format json`. Resolves {ok, result}. */
function runClaudeJson({ prompt, tools = "", model }) {
return new Promise((res) => {
const child = spawn(CLAUDE, ["-p", prompt, "--output-format", "json", "--model", model || DEFAULT_MODEL, "--allowedTools", tools], { cwd: REPO, env: agentEnv() });
let out = "";
child.stdout.on("data", (d) => (out += d));
child.on("close", () => {
try { const env = JSON.parse(out); res({ ok: true, result: typeof env.result === "string" ? env.result : "" }); }
catch { res({ ok: false, result: "" }); }
});
child.on("error", () => res({ ok: false, result: "" }));
});
}
/** Streaming: `claude -p … --output-format stream-json`. Calls onMessage per NDJSON line; returns the child so the caller wires close/error. */
function runClaudeStream({ prompt, tools = AGENT_TOOLS, model, resume, onMessage, onStderr, onClose, onError }) {
const args = ["-p", prompt, "--output-format", "stream-json", "--verbose", "--allowedTools", tools, "--model", model || DEFAULT_MODEL];
if (resume) args.push("--resume", resume);
const child = spawn(CLAUDE, args, { cwd: REPO, env: agentEnv() });
let buf = "";
child.stdout.on("data", (chunk) => {
buf += chunk.toString();
let nl;
while ((nl = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, nl).trim();
buf = buf.slice(nl + 1);
if (!line) continue;
let msg; try { msg = JSON.parse(line); } catch { continue; }
onMessage && onMessage(msg);
}
});
child.stderr.on("data", (d) => onStderr && onStderr(d.toString()));
child.on("close", (code) => onClose && onClose(code));
child.on("error", (err) => onError && onError(err));
return child;
}
// ---- build / git / php-cli ---------------------------------------------------
function build(extraEnv) {
return new Promise((res) => {
const child = spawn("npm", ["run", "build"], { cwd: APP, env: { ...agentEnv(), ...extraEnv } });
let tail = "";
const grab = (d) => { tail = (tail + d.toString()).slice(-4000); };
child.stdout.on("data", grab);
child.stderr.on("data", grab);
child.on("close", (code) => res({ ok: code === 0, tail }));
child.on("error", () => res({ ok: false, tail: "build failed to start" }));
});
}
function git(args) {
return new Promise((res) => {
const child = spawn("git", args, { cwd: REPO, env: agentEnv() });
let tail = "";
child.stdout.on("data", (d) => (tail += d));
child.stderr.on("data", (d) => (tail += d));
child.on("close", (code) => res({ ok: code === 0, tail: tail.toString() }));
child.on("error", () => res({ ok: false, tail: "" }));
});
}
function phpCli(args) {
return new Promise((res) => {
const child = spawn(PHP, args, { cwd: REPO, env: agentEnv() });
let out = "", err = "";
child.stdout.on("data", (d) => (out += d));
child.stderr.on("data", (d) => (err += d));
child.on("close", () => res({ out, err }));
child.on("error", () => res({ out: "", err: "php spawn failed" }));
});
}
// ---- HTTP helpers ------------------------------------------------------------
function json(res, status, obj) {
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
res.end(JSON.stringify(obj));
}
function readBody(req) {
return new Promise((res) => {
let b = "";
req.on("data", (c) => (b += c));
req.on("end", () => { try { res(b ? JSON.parse(b) : {}); } catch { res({}); } });
});
}
// ---- SSE job model -----------------------------------------------------------
const jobs = new Map();
function newJob(page) {
const conversationId = randomUUID();
const job = {
conversationId,
jobId: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`,
sessionId: null,
page: page || "/",
events: [],
clients: new Set(),
done: false,
};
jobs.set(conversationId, job);
return job;
}
function emit(job, ev) {
job.events.push(ev);
const line = `data: ${JSON.stringify(ev)}\n\n`;
for (const res of job.clients) { try { res.write(line); } catch {} }
}
// ---- the host singleton ------------------------------------------------------
const _routes = [];
const _schedules = [];
export const host = {
// paths / constants (Phase 2: source from config)
REPO, APP, PREVIEW_DIR, CLAUDE, PHP, PORT, AGENT_TOOLS, DEFAULT_MODEL, gitScope: GIT_SCOPE,
// node fs re-exports agents need
rmSync, join,
// shared single-flight lock (mutable by reference across agent modules)
state: { busy: false },
// env + claude + ops + http + SSE
loadEnv, saveToken, agentEnv,
runClaudeJson, runClaudeStream,
build, git, phpCli,
json, readBody,
jobs, newJob, emit,
/** Register an HTTP route. method "ANY" matches any verb. */
route(method, path, handler) { _routes.push({ method, path, handler }); },
/** Run fn every `min` minutes, plus one pulse `bootDelayMs` after listen(). */
everyMinutes(min, fn, { bootDelayMs = 90_000 } = {}) {
if (min > 0) _schedules.push({ min, fn, bootDelayMs });
},
/** Start the server + scheduled tasks. */
listen() {
const server = createServer(async (req, res) => {
const url = new URL(req.url, "http://localhost");
const path = url.pathname.replace(/^\/devconsole/, "") || "/";
const method = req.method || "GET";
try {
const route = _routes.find((r) => r.path === path && (r.method === "ANY" || r.method === method));
if (!route) return json(res, 404, { error: "not found" });
await route.handler(req, res, { url, path, method });
} catch (err) { json(res, 500, { error: err.message }); }
});
if (!existsSync(PREVIEW_DIR)) mkdirSync(PREVIEW_DIR, { recursive: true });
server.listen(PORT, "127.0.0.1", () => console.log(`[console] runner on 127.0.0.1:${PORT}, repo ${REPO}`));
for (const s of _schedules) {
setTimeout(s.fn, s.bootDelayMs);
setInterval(s.fn, s.min * 60_000);
}
},
};
// ---- core routes (token/health — framework-level, agent-independent) ---------
host.route("ANY", "/ping", (req, res) => {
const env = loadEnv();
json(res, 200, { authed: true, hasToken: Boolean(env.CLAUDE_CODE_OAUTH_TOKEN) });
});
host.route("POST", "/auth", async (req, res) => {
const { token } = await readBody(req);
if (!token || !/^sk-ant-/.test(token)) return json(res, 400, { error: "That doesn't look like a Claude token (expected sk-ant-…)." });
saveToken(token.trim());
json(res, 200, { ok: true });
});
host.route("POST", "/logout", (req, res) => json(res, 200, { ok: true })); // admin session cleared by PHP

File diff suppressed because it is too large Load diff

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

@ -0,0 +1,49 @@
/**
* 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; 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 || {},
},
};

71
api/cli/qa-autofix.php Normal file
View file

@ -0,0 +1,71 @@
<?php
/**
* Auto-queue Web Designer fix tasks for the SAFE QA findings of a run the ones
* with a clear, low-risk fix (missing/empty alt text, broken internal links).
* Judgment calls (external links, images, SEO opinions) are left for the human
* via the admin's per-finding instructions box.
*
* php api/cli/qa-autofix.php --run=ID -> {"queued": N}
*
* Dedup: skips a finding if a matching fix task is already queued/running, or was
* created in the last 6 hours so hourly heartbeats never spam or loop on the
* same issue.
*/
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../config.php';
const SAFE = ['a11y', 'link'];
$opts = getopt('', ['run:']);
$runId = (int) ($opts['run'] ?? 0);
if ($runId <= 0) { echo json_encode(['queued' => 0]); exit; }
$pdo = new PDO(
sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', DB_HOST, DB_NAME),
DB_USER,
DB_PASS,
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC]
);
$in = implode(',', array_fill(0, count(SAFE), '?'));
$sel = $pdo->prepare(
"SELECT finding_id, check_type, url, detail, fix_hint
FROM cja_qa_findings
WHERE run_id = ? AND status = 'open' AND check_type IN ($in)"
);
$sel->execute([$runId, ...SAFE]);
$dupe = $pdo->prepare(
"SELECT COUNT(*) FROM cja_tasks
WHERE title = ? AND (status IN ('queued','running') OR created_at > (NOW() - INTERVAL 6 HOUR))"
);
$order = (int) $pdo->query('SELECT COALESCE(MAX(sort_order), 0) FROM cja_tasks')->fetchColumn();
$ins = $pdo->prepare(
'INSERT INTO cja_tasks (title, target_page, prompt, draft, assets, status, sort_order)
VALUES (?, ?, ?, "", ?, "queued", ?)'
);
$mark = $pdo->prepare("UPDATE cja_qa_findings SET status = 'fix_queued' WHERE finding_id = ?");
$queued = 0;
foreach ($sel as $f) {
$url = (string) $f['url'];
$title = 'QA fix: ' . $f['check_type'] . ' — ' . mb_substr($url, 0, 60);
$dupe->execute([$title]);
if ((int) $dupe->fetchColumn() > 0) continue;
$target = (str_starts_with($url, '/') && !str_contains($url, ',') && !str_contains($url, '://'))
? explode('#', explode('?', $url)[0])[0]
: '/';
$prompt = ($f['fix_hint'] !== null && $f['fix_hint'] !== '')
? (string) $f['fix_hint']
: "QA found a {$f['check_type']} issue on {$url}: {$f['detail']}. Fix it.";
$order += 10;
$ins->execute([mb_substr($title, 0, 200), mb_substr($target, 0, 200), $prompt, json_encode(['images' => [], 'videos' => []]), $order]);
$mark->execute([$f['finding_id']]);
$queued++;
}
echo json_encode(['queued' => $queued]);

View file

@ -58,6 +58,7 @@ $entries = [
['fixed', 'Sitemap now covers the whole site', 'Projects, services, resume, changelog, and FAQ are listed in sitemap.xml, so search engines can find every page.', '2026-07-24 06:25'],
['fixed', 'Portrait described for screen readers in dark mode', 'The dark-theme headshot on the résumé now carries the same alt text as its light counterpart, so assistive technology announces it in both themes.', '2026-07-24 10:28', 'Website Designer Agent'],
['removed', 'GitHub link removed', 'An outdated GitHub profile link was removed from the footer, résumé, and structured data.', '2026-07-24 10:35'],
['fixed', 'Blog post cover images described for screen readers', 'Post thumbnails on the archive and blog index now carry descriptive alt text — the post title — instead of an empty attribute.', '2026-07-24 10:39', 'Website Designer Agent'],
];
Db::execute('TRUNCATE TABLE cja_changelog');

View file

@ -82,9 +82,16 @@ class AdminQa extends PublicController
$target = (str_starts_with($url, '/') && !str_contains($url, ',') && !str_contains($url, '://'))
? explode('#', explode('?', $url)[0])[0]
: '/';
$prompt = $f['fix_hint'] !== null && $f['fix_hint'] !== ''
? (string) $f['fix_hint']
: "QA found a {$f['check_type']} issue on {$url}: {$f['detail']}. Investigate and fix it.";
// Optional human instructions override the templated fix hint — this is
// the admin's "tell the Web Designer how to handle it" path.
$instructions = trim((string) ($in['instructions'] ?? ''));
if ($instructions !== '') {
$prompt = $instructions . "\n\n(QA context: a {$f['check_type']} issue on {$url}{$f['detail']})";
} else {
$prompt = $f['fix_hint'] !== null && $f['fix_hint'] !== ''
? (string) $f['fix_hint']
: "QA found a {$f['check_type']} issue on {$url}: {$f['detail']}. Investigate and fix it.";
}
$title = 'QA fix: ' . $f['check_type'] . ' — ' . mb_substr($url, 0, 60);
$order = (int) \Db::getValue('SELECT COALESCE(MAX(sort_order), 0) + 10 FROM cja_tasks');

View file

@ -70,7 +70,7 @@ const thumbnailIsString = typeof post.thumbnail === "string";
thumbnailIsString ? (
<img
src={post.thumbnail}
alt=""
alt={post.title}
width="440"
height="330"
loading="lazy"
@ -79,7 +79,7 @@ const thumbnailIsString = typeof post.thumbnail === "string";
) : (
<Image
src={post.thumbnail}
alt=""
alt={post.title}
loading="lazy"
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]"
/>
@ -105,7 +105,7 @@ const thumbnailIsString = typeof post.thumbnail === "string";
thumbnailIsString ? (
<img
src={post.thumbnail}
alt=""
alt={post.title}
width="640"
height="400"
loading="lazy"
@ -114,7 +114,7 @@ const thumbnailIsString = typeof post.thumbnail === "string";
) : (
<Image
src={post.thumbnail}
alt=""
alt={post.title}
loading="lazy"
class="h-full w-full object-cover transition-transform duration-500 group-hover:scale-[1.02]"
/>

View file

@ -48,6 +48,9 @@ import AdminLayout from "../../layouts/AdminLayout.astro";
.fnd .ck { font-family:var(--mono); font-size:.6rem; letter-spacing:.08em; text-transform:uppercase; color:var(--ink4); }
.fnd .url { font-family:var(--mono); font-size:.78rem; color:var(--ink2); word-break:break-all; }
.fnd .detail { margin:.25rem 0 0; font-size:.88rem; color:var(--ink3); }
.fnd .inst { margin-top:.5rem; width:100%; border:0; border-bottom:1px solid var(--rule); background:transparent;
padding:.35rem 0; font-size:.82rem; color:var(--ink); font-family:var(--sans); }
.fnd .inst:focus { outline:none; border-bottom-color:var(--seal); }
.fnd .acts { flex:0 0 auto; display:flex; gap:.5rem; align-items:center; }
.fnd .acts button { font-family:var(--mono); font-size:.58rem; letter-spacing:.06em; text-transform:uppercase;
background:none; border:1px solid var(--rule); border-radius:2px; padding:.35rem .6rem; color:var(--ink3); cursor:pointer; }
@ -139,11 +142,14 @@ import AdminLayout from "../../layouts/AdminLayout.astro";
const acts = f.status === "fix_queued"
? `<span class="state">fix queued</span>`
: `<button class="fix" data-fix="${f.id}">Create fix task</button><button class="ignore" data-ignore="${f.id}">Ignore</button>`;
const inst = f.status === "fix_queued" ? ""
: `<input class="inst" data-inst="${f.id}" placeholder="Optional — tell the Web Designer how to fix this (e.g. “remove it”, “use https://…”)">`;
row.innerHTML = `
<span class="sev ${f.severity}"></span>
<div class="body">
<div class="top"><span class="ck">${esc(f.check)}</span><span class="url">${esc(f.url)}</span></div>
<p class="detail">${esc(f.detail)}</p>
${inst}
</div>
<div class="acts">${acts}</div>`;
const fixBtn = row.querySelector("[data-fix]");
@ -158,9 +164,10 @@ import AdminLayout from "../../layouts/AdminLayout.astro";
async function createFix(id, runId) {
try {
const instructions = document.querySelector(`[data-inst="${id}"]`)?.value.trim() || "";
const r = await fetch("/api/adminqa/fix", {
method: "POST", credentials: "same-origin",
headers: { "content-type": "application/json" }, body: JSON.stringify({ finding_id: id }),
headers: { "content-type": "application/json" }, body: JSON.stringify({ finding_id: id, instructions }),
});
const d = await r.json();
if (!r.ok || !d?.data?.ok) { setMsg(d?.error?.message || "Couldn't queue the fix.", "error"); return; }

View file

@ -25,5 +25,11 @@
"route": "/devconsole",
"tokenFile": "agents/.env",
"tokenKey": "ADMIN_TOKEN"
},
"agents": {
"enabled": ["in-page-console", "project-builder", "web-designer", "qa"],
"settings": {
"qa": { "heartbeatMin": 60, "autofix": true }
}
}
}

View file

@ -57,6 +57,14 @@ if (existsSync(resolve(root, "astroagent.config.json"))) {
aa.name = slug(site.name);
aa.url = site.url;
if (aa.ai) aa.ai.agentUser = `${slug(site.name)}-agent`;
// Ensure a console agent roster exists (which agents this clone runs). The
// enabled list + settings are the operator's to edit; only default if absent.
if (!aa.agents) {
aa.agents = {
enabled: ["in-page-console", "project-builder", "web-designer", "qa"],
settings: { qa: { heartbeatMin: 60, autofix: true } },
};
}
write("astroagent.config.json", aa);
}