chore: remove proprietary agent framework (agents/ + astroagent) from seed

The Node content pipeline (agents/) and the astroagent in-site authoring
console are a proprietary, CLI-strategy agent system that shouldn't ship in
the cloneable SeedProject base. Remove both and all their wiring:

- Delete agents/ (pipeline scripts, prompts, admin server, libs) + runtime
- Delete astroagent config, app/.astroagent/ skill, DevConsole component;
  unhook it from BaseLayout.astro and astro.config.mjs preview env logic
- Delete api/cli/resources.php (DB->pipeline resource bridge)
- Drop agent blocks from configure.mjs, agent runtime rules from .gitignore,
  topic/audience from site.config.json, agent prompts from new-site.sh
- Strip agent sections from AGENTS.md / README.md / api/.memory/foundation.md

Kept: api/app/LLM/* (API-key/REST multi-provider layer — distinct from the
CLI agents), api/cli/rebuild.php, and the mde_resources schema.

Frontend build verified (npm run build → 14 pages).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DfzaSFv5okUxCCdvq5RXu1
This commit is contained in:
Carlos Arias 2026-07-24 10:54:51 -05:00
parent 2c969c0753
commit e13c0b9e19
45 changed files with 24 additions and 4114 deletions

21
.gitignore vendored
View file

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

View file

@ -29,13 +29,12 @@ on the site afterward.
## The golden rule: one identity source ## The golden rule: one identity source
`site.config.json` (repo root) is the **single source of truth** for site identity — `site.config.json` (repo root) is the **single source of truth** for site identity —
name, URL, description, tagline, author, social, topic, audience, timezone. name, URL, description, tagline, author, social, timezone.
- **Never hardcode** the site name, URL, author, or niche in components, pages, or engine - **Never hardcode** the site name, URL, or author in components, pages, or engine
configs. configs.
- To change identity: edit `site.config.json`, then run `node scripts/configure.mjs`. - To change identity: edit `site.config.json`, then run `node scripts/configure.mjs`.
That regenerates `app/src/config/site.json` (the theme reads it) and updates That regenerates `app/src/config/site.json` (the theme reads it).
`agents/config.json` and `astroagent.config.json`.
- In the Astro theme, read identity from `SITE`, `authors`, etc. exported by - In the Astro theme, read identity from `SITE`, `authors`, etc. exported by
`app/src/lib/blog-data.js` (which imports `app/src/config/site.json`). `app/src/lib/blog-data.js` (which imports `app/src/config/site.json`).
@ -48,7 +47,6 @@ name, URL, description, tagline, author, social, topic, audience, timezone.
| `scripts/configure.mjs` | Stamp `site.config.json` into every engine | | `scripts/configure.mjs` | Stamp `site.config.json` into every engine |
| `app/` | Astro frontend ("theme"). Build → `../public`. See `app/AGENTS.md` for coding standards. | | `app/` | Astro frontend ("theme"). Build → `../public`. See `app/AGENTS.md` for coding standards. |
| `api/` | SeedProject PHP backend, served at `/api` (see below) | | `api/` | SeedProject PHP backend, served at `/api` (see below) |
| `agents/` | LLM agents: orchestrators + prompts + config. Catalog: `agents/AGENTS.md` (generated) |
| `public/` | Build output (git-ignored) | | `public/` | Build output (git-ignored) |
## Backend (`api/`) ## Backend (`api/`)
@ -70,24 +68,9 @@ php console db:migrate --status
- Web-server setup (aliasing `/api``api/` with PHP-FPM + front-controller rewrite): - Web-server setup (aliasing `/api``api/` with PHP-FPM + front-controller rewrite):
see `api/.memory/foundation.md`. see `api/.memory/foundation.md`.
## Content pipeline & authoring ## Authoring content
- `agents/` runs the autonomous content system. Each agent script carries an - To add a post: create `app/src/content/blog/<slug>/index.mdx` following the
`@agent-manifest` header (class, trigger, model, prompts, skills, tools, tables);
`node agents/catalog.mjs` regenerates the catalog (`agents/AGENTS.md` +
`agents/agents.json` — the machine manifest a clone can diff to find missing deps).
`configure.mjs` stamps the site name/topic/audience/author into `agents/config.json`
and regenerates the catalog; runtime state (`drafts/`, `state/`, `logs/`) is
git-ignored and regenerates per site.
- **⚠️ NOT yet niche-generic — do not trust the pipeline output as-is.** The prompts and
some scripts (`agents/prompts/*.system.md`, `agents/scripts/*.mjs`)
still contain wording from the original site (a Medellín restaurant publication) and do
**not** read the niche from config. Running the pipeline before genericizing it will
produce off-niche content. Fix: make those files read `config.site.{name,topic,audience}`.
See [Status & known gaps](#status--known-gaps).
- **astroagent** is the in-site authoring console; its identity is in
`astroagent.config.json`.
- To add a post by hand: create `app/src/content/blog/<slug>/index.mdx` following the
schema in `app/src/content.config.js` (copy the sample `welcome` post as a template). schema in `app/src/content.config.js` (copy the sample `welcome` post as a template).
## Local development ## Local development
@ -106,12 +89,6 @@ Read this before relying on any subsystem:
- ⚠️ **`/api` Foundation** — code is written and code-verified (composer, `php console`, - ⚠️ **`/api` Foundation** — code is written and code-verified (composer, `php console`,
autoload, graceful CLI failures), but the **live DB + HTTP round-trip is UNVERIFIED** autoload, graceful CLI failures), but the **live DB + HTTP round-trip is UNVERIFIED**
(never run on a served instance with a real database). (never run on a served instance with a real database).
- ⚠️ **Content pipeline** — prompts/scripts are **still niche-specific** (Medellín
restaurants) AND the whole pipeline is **JSON-file-based** (`config.json`, `calendar.json`,
the `*-queue`/`*-scan` files, `state/`, `drafts/`). It is **slated for a DB-managed
rewrite** (that state moves into MySQL via the `/api` backend). **Do NOT invest in
genericizing the current JSON pipeline** — genericize the prompts as part of the DB
rewrite. Treat the pipeline as niche-specific placeholder scaffolding until then.
## Guardrails ## Guardrails

View file

@ -1,19 +1,16 @@
# SeedProject base # SeedProject base
A cloneable foundation for building websites — a static [Astro](https://astro.build) A cloneable foundation for building websites — a static [Astro](https://astro.build)
frontend with a PHP backend, an AI authoring console, and an autonomous content frontend with a PHP backend. Clone it, set one config file, and build out a new
pipeline. Clone it, set one config file, and build out a new site (a lawyer's site (a lawyer's office, a roofing company, a niche publication) the way you'd spin
office, a roofing company, a niche publication) the way you'd spin up a new up a new WordPress install — but faster to host and safer to run.
WordPress install — but faster to host and safer to run.
## What's inside ## What's inside
| Path | Role | "WordPress equivalent" | | Path | Role | "WordPress equivalent" |
|------|------|------------------------| |------|------|------------------------|
| `app/` | Astro theme — layouts, components, content collections, sample post | theme + posts | | `app/` | Astro theme — layouts, components, content collections, sample post | theme + posts |
| `api/` | [SeedProject](api/.memory/documentation.md) PHP framework — the dynamic backend (DB, forms, metrics, agents) | PHP core | | `api/` | [SeedProject](api/.memory/documentation.md) PHP framework — the dynamic backend (DB, forms, metrics) | PHP core |
| `agents/` | Autonomous content engine — research, write, review, publish | (no equivalent) |
| `astroagent.config.json` + `app/.astroagent/` | AI authoring console — change the site in plain English | wp-admin |
| `site.config.json` | **Single source of site identity** — the one file you edit per site | wp-config + Site Settings | | `site.config.json` | **Single source of site identity** — the one file you edit per site | wp-config + Site Settings |
## Quick start — spin up a new site ## Quick start — spin up a new site
@ -54,9 +51,8 @@ After editing it, run:
node scripts/configure.mjs node scripts/configure.mjs
``` ```
This stamps the values into the Astro theme (`app/src/config/site.json`), This stamps the values into the Astro theme (`app/src/config/site.json`).
`agents/config.json`, and `astroagent.config.json` so all three engines `new-site.sh` runs it for you.
share one identity. `new-site.sh` runs it for you.
## Backend (`api/`) ## Backend (`api/`)
@ -76,13 +72,10 @@ The `api/` directory is the SeedProject PHP framework, meant to be served at
> round-trip. Design + implementation plan: [`api/.memory/foundation.md`](api/.memory/foundation.md) > round-trip. Design + implementation plan: [`api/.memory/foundation.md`](api/.memory/foundation.md)
> and [`api/.memory/foundation-plan.md`](api/.memory/foundation-plan.md). > and [`api/.memory/foundation-plan.md`](api/.memory/foundation-plan.md).
## Content pipeline & authoring ## Authoring content
- **`agents/`** — autonomous research/write/review/publish scripts driven Add a post by creating `app/src/content/blog/<slug>/index.mdx` following the
by `agents/config.json` (populated from `site.config.json`). Runtime schema in `app/src/content.config.js` (copy the sample `welcome` post as a template).
state (`drafts/`, `state/`, `logs/`, queues) is git-ignored and regenerates per site.
- **astroagent** — the in-site console for making changes in plain English; its
identity comes from `astroagent.config.json`.
## Directory layout ## Directory layout
@ -94,7 +87,6 @@ my-site/
│ └── configure.mjs ← stamp site.config.json into every engine │ └── configure.mjs ← stamp site.config.json into every engine
├── app/ ← Astro frontend (build → ../public) ├── app/ ← Astro frontend (build → ../public)
├── api/ ← SeedProject PHP backend (served at /api) ├── api/ ← SeedProject PHP backend (served at /api)
├── agents/ ← autonomous content engine
└── public/ ← build output (git-ignored) └── public/ ← build output (git-ignored)
``` ```

View file

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

View file

@ -1,134 +0,0 @@
# Comiida Content Pipeline
An agentic pipeline that plans a 3-month editorial calendar and drafts one SEO/EEAT
restaurant article per day (English, for Medellín expats/tourists) with an AI cover image,
queued for human approval before going live on the Astro site.
Engine: **headless Claude Code** (`claude -p`) + **Higgsfield** image MCP (authenticated at
the claude.ai level — reachable headless, confirmed). See the design at
`/root/.claude/plans/init-elegant-giraffe.md`.
## Layout
- `config.json` — site, author, models, image, editorial mix/word-counts.
- `.env` — optional secrets (git-ignored). None required today (Higgsfield auth is global).
- `prompts/` — system prompts: `research.system.md`, `writer.system.md`, `image.md`.
- `scripts/``research.mjs`, `write-daily.mjs`, `list-drafts.mjs`, `approve.mjs`, `lib/`.
- `calendar.json` — the generated editorial calendar (created by `research.mjs`).
- `drafts/<slug>/` — pending drafts (`index.mdx`, `cover.jpg`, `sources.json`).
- `run.sh` — cron-safe wrapper (sets PATH, loads `.env`).
## Setup (one-time)
1. Add the real author to `app/src/lib/blog-data.js` and set `author.slug`/`name` in
`config.json` to match (EEAT requires a real byline).
2. Image generation needs no key — it uses the Higgsfield claude.ai MCP. Check credits with
the `balance` tool if generations start failing.
## Usage
```bash
# Plan ~3 months (writes calendar.json). Default days = config.editorial.calendarDays.
./run.sh research.mjs [days]
# Draft the next due article into drafts/<slug>/ (auto-runs the SEO audit after).
./run.sh write-daily.mjs [slug]
# SEO Specialist audit (EEAT / spam-policy / on-page / AEO / readability).
# Writes <postDir>/seo-review.json. Default audits the draft; --published audits the live post.
./run.sh seo-review.mjs <slug> [--published]
# Auto-revise a failing draft until it passes the SEO gate (or maxReviseAttempts).
./run.sh revise.mjs <slug>
# News radar — discover timely Medellín food news/events → news-queue.json.
# (also runs automatically inside write-daily, once per day)
./run.sh news-radar.mjs
# Research — top up the evergreen backlog (additive merge into calendar.json).
# (also runs automatically inside write-daily when the planned backlog is low)
./run.sh research.mjs [count]
# Review the queue, then publish one manually (SEO-gated; --force overrides).
node scripts/list-drafts.mjs
./run.sh approve.mjs <slug> [--force] # moves into the blog, builds, goes live
# Randomized auto-publisher (cron). Publishes ONE SEO-passing draft per day at a random time.
./run.sh publish-tick.mjs [--now] # --now ignores the time gate (for testing)
```
## Scheduling (managed in the aaPanel Cron UI; server clock is UTC)
Only **two** jobs are scheduled — research is now demand-driven (see below), not a cron.
```
0 11 * * * run.sh write-daily.mjs # daily 11:00 UTC (06:00 Bogotá) — news scan + refill + draft + SEO audit
*/15 * * * * run.sh publish-tick.mjs # every 15m — publish 1 passing draft in the morning (random timestamp)
```
(aaPanel runs these in UTC; `publish-tick` computes its Bogotá floor time internally.)
## Evergreen backlog is demand-driven (no monthly cron)
`research.mjs` is **additive** — it proposes evergreen topics and merges new ones into
`calendar.json` (deduped, dates auto-assigned). `write-daily` **auto-refills** when the
`planned` backlog drops below `editorial.refillThreshold` (guarded once/day), so the calendar
stays stocked without thinking in "months." Run manually any time: `./run.sh research.mjs [count]`.
## Concurrency safety
All `calendar.json` writes go through `lib/calendar.mjs` — an exclusive **lockfile** plus
atomic read-modify-write (`updateEntry`/`addEntry`/`mergeEntries`). This prevents the
lost-update race where a long `write-daily` run could clobber concurrent `publish-tick` /
research writes.
## Auto-publish: morning go-live, randomized timestamp (`config.json` → `publish`)
Posts go **live in the morning** (not a random hour all day), but each is stamped with a
**random earlier-today timestamp** so published times aren't a fixed-minute fingerprint:
- **06:00** `write-daily.mjs` drafts the day's topic, runs the SEO audit, and auto-revises.
- **`publish-tick.mjs`** (every 15 min) publishes **one** SEO-passing draft on the first tick
after the **`publish.notBefore`** floor (default `07:00` Bogotá — a buffer so the morning's
fresh/news article is the one that goes live). The post's `date` is set to a random time
between midnight and the publish moment (`randomTimestampTodaySoFar` — varied but never
future). News drafts fast-track ahead of evergreen. One publish/day; idempotent; SEO-failing
drafts wait for manual review. State in `state/publish-YYYY-MM-DD.json`.
- Set `publish.auto=false` to disable auto-publish and use manual `approve.mjs`.
## Admin dashboard (read-only)
A small Node service (`admin/server.mjs`, no deps) shows the week's scheduled topics, the
draft queue (with cover + preview), and the full calendar.
- Served at **`https://comiida.com/admin?key=<ADMIN_TOKEN>`** (Apache proxies `/admin`
`127.0.0.1:3010`; token is in `.env`). A valid `?key=` sets a 30-day cookie.
- Kept alive by **supervisor**: program `comiidaAdmin`
(`/www/server/panel/plugin/supervisor/profile/comiidaAdmin.ini`).
Manage with: `supervisorctl -c /etc/supervisor/supervisord.conf {status|restart|stop} comiidaAdmin:comiidaAdmin_00`.
- Read-only by design — approving still happens via `./run.sh approve.mjs <slug>`.
## Daily news radar (timely news/events)
`scripts/news-radar.mjs` + `prompts/news-radar.system.md` discover **time-sensitive** Medellín
food items (openings/closings, festivals/events, awards, press) via WebSearch → `news-queue.json`
(deduped vs published + calendar; stale fresh items pruned past `news.recencyDays`).
- Runs **automatically at the start of `write-daily.mjs`**, guarded once/day via
`state/news-YYYY-MM-DD.json` (so the hourly test cron doesn't re-scan).
- **News-first selection:** the writer prefers the freshest queue item (by `freshnessScore`),
injects it into `calendar.json` as a `news:true` entry, and drafts it; on a quiet day it falls
back to the next evergreen calendar topic.
- **Fast-track publishing:** `publish-tick.mjs` releases `news:true` drafts before evergreen
ones (still 1/day, still SEO-gated, still randomized timestamp) — so timely pieces go live the
same day.
- Config: `config.json → news { enabled, recencyDays, maxPerScan }`.
## Auto-revise loop
When a fresh draft fails the SEO gate, `write-daily` automatically runs `scripts/revise.mjs`:
it feeds the audit's `blocking` + `topFixes` back to the writer (`prompts/reviser.system.md`),
which edits the draft in place (finding real sources via WebSearch for uncited claims — never
fabricating), then **re-audits**. Repeats until `pass` or `seo.maxReviseAttempts` (config).
Config: `seo.autoRevise`, `seo.maxReviseAttempts`. Verified: a draft at fail/82 → pass/90 in one pass.
## SEO Specialist agent
`scripts/seo-review.mjs` + `prompts/seo-review.system.md` audit an article across five
weighted dimensions — EEAT (30), spam-policy compliance (20), on-page SEO (25), AEO/answer-
engine (15), readability (10) — and write `seo-review.json` with per-dimension scores, a
verdict (`pass`/`revise`/`fail`), blocking issues, and a ranked fix list.
- Runs **automatically** at the end of `write-daily.mjs` (verdict also stored on the calendar
entry).
- **Gates `approve.mjs`**: approval is blocked when `verdict==="fail"` or `overall <
seo.minScore` (config; default 85). Override with `approve.mjs <slug> --force`.
- Verdict + scores + fixes show in the `/admin` dashboard (draft cards and the draft preview).
- Reviewer model: `models.reviewer` in `config.json`.
## EEAT guardrails (enforced in prompts)
Data-driven only; every claim cited; no fabricated first-person dining; real author byline;
honest "AI-generated illustration" image credit.

View file

@ -1,456 +0,0 @@
#!/usr/bin/env node
// Read-only admin dashboard for the Comiida content pipeline.
// Serves at /admin (proxied by Apache). Auth via secret token (?key= or cookie).
// Node built-ins only — no external deps.
import { createServer } from "node:http";
import { randomUUID } from "node:crypto";
import { readFileSync, writeFileSync, existsSync, readdirSync } from "node:fs";
import { dirname, resolve, join, extname } from "node:path";
import { fileURLToPath } from "node:url";
import { readFrontmatter, fmString, fmArray, loadJson, todayInTz, addDays, slugify } from "../scripts/lib/util.mjs";
import { addEntry } from "../scripts/lib/calendar.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const PIPELINE = resolve(HERE, "..");
// --- env (.env) ---
function loadEnv() {
const p = join(PIPELINE, ".env");
if (!existsSync(p)) return;
for (const line of readFileSync(p, "utf8").split("\n")) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m && !(m[1] in process.env)) process.env[m[1]] = m[2];
}
}
loadEnv();
const cfg = loadJson(join(PIPELINE, "config.json"));
const root = cfg.paths.projectRoot;
const TOKEN = process.env.ADMIN_TOKEN || "";
const PORT = parseInt(process.env.ADMIN_PORT, 10) || 3010;
if (!TOKEN) {
console.error("[admin] ADMIN_TOKEN not set in .env — refusing to start.");
process.exit(1);
}
// --- data gathering ---
const draftsDir = () => join(root, cfg.paths.draftsDir);
const blogDir = () => join(root, cfg.paths.blogContentDir);
function calendar() {
const p = join(root, cfg.paths.calendar);
return existsSync(p) ? loadJson(p) : [];
}
function isPublished(slug) {
return existsSync(join(blogDir(), slug, "index.mdx"));
}
function draftSlugs() {
const d = draftsDir();
return existsSync(d)
? readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).map((x) => x.name)
: [];
}
function draftMeta(slug) {
const mdx = join(draftsDir(), slug, "index.mdx");
if (!existsSync(mdx)) return null;
const fm = readFrontmatter(readFileSync(mdx, "utf8"));
let seo = null;
const seoPath = join(draftsDir(), slug, "seo-review.json");
if (existsSync(seoPath)) {
try {
seo = JSON.parse(readFileSync(seoPath, "utf8"));
} catch {
seo = null;
}
}
return {
slug,
title: fmString(fm, "title") || slug,
category: fmString(fm, "category") || "",
tags: fmArray(fm, "tags"),
date: fmString(fm, "date") || "",
hasCover: existsSync(join(draftsDir(), slug, "cover.jpg")),
seo,
};
}
// --- helpers ---
const esc = (s) =>
String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
const STATUS_COLORS = {
planned: "#8a8f98",
drafting: "#d98324",
drafted: "#2f6feb",
"drafted-no-image": "#b58900",
"draft-failed": "#cb2431",
published: "#1a7f37",
};
const badge = (status) =>
`<span style="background:${STATUS_COLORS[status] || "#666"};color:#fff;border-radius:999px;padding:2px 10px;font-size:12px;white-space:nowrap">${esc(status)}</span>`;
const newsBadge = `<span style="background:#b5179e;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">NEWS</span>`;
const suggestedBadge = `<span style="background:#7048e8;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">SUGGESTED</span>`;
function freshNewsCount() {
const p = join(root, "agents", "news-queue.json");
if (!existsSync(p)) return 0;
try {
return JSON.parse(readFileSync(p, "utf8")).filter((i) => i.status === "fresh").length;
} catch {
return 0;
}
}
const SEO_COLORS = { pass: "#1a7f37", revise: "#d98324", fail: "#cb2431" };
const seoBadge = (seo) =>
seo
? `<span style="background:${SEO_COLORS[seo.verdict] || "#666"};color:#fff;border-radius:999px;padding:2px 10px;font-size:12px;white-space:nowrap">SEO ${esc(seo.verdict)} ${esc(seo.overall)}</span>`
: `<span style="background:#aaa;color:#fff;border-radius:999px;padding:2px 10px;font-size:12px">SEO —</span>`;
const messagesPath = () => join(root, "agents", "messages.json");
function messages() {
const p = messagesPath();
if (!existsSync(p)) return [];
try {
return JSON.parse(readFileSync(p, "utf8"));
} catch {
return [];
}
}
function authed(req) {
const url = new URL(req.url, "http://x");
const qkey = url.searchParams.get("key");
if (qkey && qkey === TOKEN) return { ok: true, setCookie: true };
const cookie = (req.headers.cookie || "").match(/(?:^|;\s*)admin_key=([^;]+)/);
if (cookie && decodeURIComponent(cookie[1]) === TOKEN) return { ok: true };
return { ok: false };
}
// --- pages ---
function dashboardHtml() {
const today = todayInTz(cfg.editorial.timezone);
const weekEnd = addDays(today, 6);
const cal = calendar().slice().sort((a, b) => (a.date || "").localeCompare(b.date || ""));
const drafts = draftSlugs().map(draftMeta).filter(Boolean);
const inWeek = cal.filter((e) => e.date >= today && e.date <= weekEnd);
const counts = cal.reduce((m, e) => ((m[e.status] = (m[e.status] || 0) + 1), m), {});
const newsSlugs = new Set(cal.filter((e) => e.news).map((e) => e.slug));
drafts.forEach((d) => (d.news = newsSlugs.has(d.slug)));
const row = (e) => `
<tr>
<td style="white-space:nowrap;color:#555">${esc(e.date)}</td>
<td><strong>${esc(e.workingTitle || e.slug)}</strong> ${e.suggested ? suggestedBadge : ""}${e.news ? " " + newsBadge : ""}<br><span style="color:#888;font-size:12px">${esc(e.primaryKeyword || "")}</span></td>
<td><span style="color:#555;font-size:13px">${esc(e.type)}</span></td>
<td>${badge(isPublished(e.slug) ? "published" : e.status)}</td>
<td>${
existsSync(join(draftsDir(), e.slug, "index.mdx"))
? `<a href="/admin/draft/${esc(e.slug)}">preview</a>`
: isPublished(e.slug)
? `<a href="${esc(cfg.site.url)}/blog/${esc(e.slug)}/" target="_blank">live ↗</a>`
: "—"
}</td>
</tr>`;
const draftCards = drafts.length
? drafts
.map(
(d) => `
<div style="border:1px solid #e2e4e8;border-radius:10px;padding:14px;display:flex;gap:14px;align-items:center">
${d.hasCover ? `<img src="/admin/cover/${esc(d.slug)}" style="width:96px;height:64px;object-fit:cover;border-radius:6px;flex:none">` : `<div style="width:96px;height:64px;background:#f0f1f3;border-radius:6px;flex:none"></div>`}
<div style="flex:1">
<div style="display:flex;align-items:center;gap:8px"><strong>${esc(d.title)}</strong> ${d.news ? newsBadge : ""} ${seoBadge(d.seo)}</div>
<div style="color:#888;font-size:12px">${esc(d.date)} · ${esc(d.category)} · ${esc(d.tags.join(", "))}</div>
${d.seo?.blocking?.length ? `<div style="color:#cb2431;font-size:12px;margin-top:4px">⚠ ${esc(d.seo.blocking.length)} blocking: ${esc(d.seo.blocking[0])}${d.seo.blocking.length > 1 ? " …" : ""}</div>` : ""}
<div style="margin-top:6px"><a href="/admin/draft/${esc(d.slug)}">preview</a> · <code style="font-size:12px">approve.mjs ${esc(d.slug)}</code></div>
</div>
</div>`
)
.join("")
: `<p style="color:#888">No drafts awaiting review.</p>`;
const countPills = Object.entries(counts)
.map(([k, v]) => `${badge(k)} <span style="color:#555">${v}</span>`)
.join("&nbsp;&nbsp;");
const msgs = messages().slice().reverse();
const msgHtml = msgs.length
? msgs
.map(
(m) => `
<div style="border:1px solid #e2e4e8;border-radius:10px;padding:12px 14px;position:relative">
<button onclick="deleteMsg('${esc(m.id || m.at)}')" title="Delete message" style="position:absolute;top:10px;right:10px;border:0;background:#f0f1f3;border-radius:6px;padding:3px 9px;cursor:pointer;color:#999;line-height:1"></button>
<div style="font-size:13px;padding-right:36px"><strong>${esc(m.name)}</strong> <span style="color:#888">&lt;${esc(m.email)}&gt;</span> <span style="color:#aaa">· ${esc((m.at || "").slice(0, 16).replace("T", " "))}</span></div>
<div style="margin-top:6px;white-space:pre-wrap">${esc(m.message)}</div>
</div>`
)
.join("")
: `<p class="muted">No messages yet.</p>`;
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>Comiida Content Admin</title>
<style>
body{font:15px/1.5 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;margin:0;background:#fafbfc}
.wrap{max-width:960px;margin:0 auto;padding:28px 20px 60px}
h1{font-size:22px;margin:0 0 4px} h2{font-size:16px;margin:30px 0 12px}
table{width:100%;border-collapse:collapse} td,th{text-align:left;padding:10px 8px;border-bottom:1px solid #eceef1;vertical-align:top}
th{font-size:12px;text-transform:uppercase;letter-spacing:.04em;color:#888}
a{color:#2f6feb;text-decoration:none} a:hover{text-decoration:underline}
.muted{color:#888;font-size:13px} .grid{display:flex;flex-direction:column;gap:10px}
.btn{display:inline-block;background:#7048e8;color:#fff;border:0;border-radius:8px;padding:9px 16px;font-size:14px;font-weight:600;cursor:pointer}
.btn:hover{opacity:.92}
.overlay{position:fixed;inset:0;background:rgba(0,0,0,.4);display:none;align-items:flex-start;justify-content:center;z-index:10}
.overlay.open{display:flex}
.modal{background:#fff;border-radius:12px;max-width:520px;width:calc(100% - 32px);margin-top:8vh;padding:22px;box-shadow:0 12px 40px rgba(0,0,0,.2)}
.modal h3{margin:0 0 4px;font-size:18px} .modal label{display:block;font-size:13px;font-weight:600;margin:14px 0 5px}
.modal input,.modal textarea{width:100%;box-sizing:border-box;border:1px solid #d6d9de;border-radius:8px;padding:9px 11px;font-size:14px;font-family:inherit}
.modal .row2{display:flex;gap:10px;justify-content:flex-end;margin-top:18px}
.modal .cancel{background:#eceef1;color:#333}
</style></head><body><div class="wrap">
<div style="display:flex;align-items:center;justify-content:space-between;gap:12px">
<h1>Comiida Content Admin</h1>
<button class="btn" onclick="document.getElementById('suggestOverlay').classList.add('open')">+ Suggest an article</button>
</div>
<div class="muted">Today ${esc(today)} (${esc(cfg.editorial.timezone)}) · ${countPills} · 📰 news queue: ${freshNewsCount()} fresh</div>
<div class="overlay" id="suggestOverlay">
<div class="modal">
<h3>Suggest an article</h3>
<div class="muted">Added as a top-priority topic the writer researches & drafts it next.</div>
<form id="suggestForm">
<label>Title *</label>
<input name="title" required maxlength="160" placeholder="e.g. The best late-night eats in Laureles" />
<label>Brief description</label>
<textarea name="description" rows="3" maxlength="600" placeholder="What angle / what to cover?"></textarea>
<label>Prompt / instructions for the agent (optional)</label>
<textarea name="instructions" rows="3" maxlength="4000" placeholder="e.g. Focus on vegan spots in Laureles; research the new Provenza openings; compare prices and hours"></textarea>
<label>Draft (optional)</label>
<textarea name="draft" rows="6" maxlength="20000" placeholder="Paste a draft or rough notes — the agent will build on, fact-check, and expand it."></textarea>
<label>Source link (optional)</label>
<input name="source" type="url" placeholder="https://… where you saw the idea" />
<div class="row2">
<button type="button" class="btn cancel" onclick="document.getElementById('suggestOverlay').classList.remove('open')">Cancel</button>
<button type="submit" class="btn" id="suggestSubmit">Add suggestion</button>
</div>
</form>
</div>
</div>
<script>
document.getElementById('suggestForm').addEventListener('submit', async (e) => {
e.preventDefault();
const btn = document.getElementById('suggestSubmit');
const f = e.target;
const payload = { title: f.title.value.trim(), description: f.description.value.trim(), instructions: f.instructions.value.trim(), draft: f.draft.value.trim(), source: f.source.value.trim() };
if (!payload.title) return;
btn.disabled = true; btn.textContent = 'Adding…';
try {
const r = await fetch('/admin/suggest', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload) });
if (!r.ok) throw new Error((await r.json()).error || 'failed');
location.reload();
} catch (err) {
btn.disabled = false; btn.textContent = 'Add suggestion';
alert('Could not add suggestion: ' + err.message);
}
});
</script>
<h2>📬 Messages (${msgs.length})</h2>
<div class="grid">${msgHtml}</div>
<script>
window.deleteMsg = async (id) => {
if (!confirm('Delete this message?')) return;
try {
const r = await fetch('/admin/message-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id }) });
if (!r.ok) throw new Error();
location.reload();
} catch { alert('Could not delete the message.'); }
};
</script>
<h2>This week (${esc(today)} ${esc(weekEnd)})</h2>
${inWeek.length ? `<table><tr><th>Date</th><th>Topic</th><th>Type</th><th>Status</th><th></th></tr>${inWeek.map(row).join("")}</table>` : `<p class="muted">Nothing scheduled this week. Run research.mjs to plan more.</p>`}
<h2>Drafts awaiting review (${drafts.length})</h2>
<div class="grid">${draftCards}</div>
<h2>Full calendar (${cal.length})</h2>
<table><tr><th>Date</th><th>Topic</th><th>Type</th><th>Status</th><th></th></tr>${cal.map(row).join("")}</table>
</div></body></html>`;
}
function draftDetailHtml(slug) {
const dir = join(draftsDir(), slug);
const mdxPath = join(dir, "index.mdx");
if (!existsSync(mdxPath)) return null;
const raw = readFileSync(mdxPath, "utf8");
const fm = readFrontmatter(raw);
const body = raw.replace(/^---\n[\s\S]*?\n---\n?/, "");
const sources = existsSync(join(dir, "sources.json"))
? readFileSync(join(dir, "sources.json"), "utf8")
: "(none)";
let seo = null;
if (existsSync(join(dir, "seo-review.json"))) {
try {
seo = JSON.parse(readFileSync(join(dir, "seo-review.json"), "utf8"));
} catch {
seo = null;
}
}
const seoSection = seo
? `<h2>SEO audit ${seoBadge(seo)}</h2>
<p class="muted">${esc(seo.summary || "")}</p>
<ul>${Object.entries(seo.dimensions || {})
.map(([k, v]) => `<li><strong>${esc(k)}</strong>: ${esc(v.score)}${v.issues?.length ? " — " + esc(v.issues.join("; ")) : ""}</li>`)
.join("")}</ul>
${seo.blocking?.length ? `<p style="color:#cb2431"><strong>Blocking:</strong></p><ul>${seo.blocking.map((b) => `<li>${esc(b)}</li>`).join("")}</ul>` : ""}
${seo.topFixes?.length ? `<p><strong>Top fixes:</strong></p><ul>${seo.topFixes.map((f) => `<li>[${esc(f.severity)}/${esc(f.area)}] ${esc(f.fix)}</li>`).join("")}</ul>` : ""}`
: `<h2>SEO audit ${seoBadge(null)}</h2><p class="muted">No audit yet. Run <code>./run.sh write-daily.mjs</code> (auto-audits) or <code>node scripts/seo-review.mjs ${esc(slug)}</code>.</p>`;
const hasCover = existsSync(join(dir, "cover.jpg"));
return `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>${esc(fmString(fm, "title") || slug)} draft</title>
<style>
body{font:15px/1.6 -apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#1a1a1a;margin:0;background:#fafbfc}
.wrap{max-width:760px;margin:0 auto;padding:24px 20px 60px}
pre{background:#f4f5f7;border:1px solid #e6e8eb;border-radius:8px;padding:12px;overflow:auto;font-size:12.5px;white-space:pre-wrap}
a{color:#2f6feb} img{max-width:100%;border-radius:8px}
h1{font-size:21px}
</style></head><body><div class="wrap">
<p><a href="/admin"> back to dashboard</a></p>
${hasCover ? `<img src="/admin/cover/${esc(slug)}">` : ""}
${seoSection}
<h2>Frontmatter</h2><pre>${esc(fm)}</pre>
<h2>Body (raw MDX)</h2><pre>${esc(body)}</pre>
<h2>sources.json</h2><pre>${esc(sources)}</pre>
<p class="muted">To publish: <code>./run.sh approve.mjs ${esc(slug)}</code></p>
</div></body></html>`;
}
// --- server ---
const send = (res, code, body, type = "text/html; charset=utf-8", extra = {}) => {
res.writeHead(code, { "content-type": type, "cache-control": "no-store", ...extra });
res.end(body);
};
const server = createServer((req, res) => {
const url = new URL(req.url, "http://x");
const path = url.pathname.replace(/\/+$/, "") || "/admin";
// PUBLIC (no auth): contact form submission → saved to messages.json, viewed in /admin.
if (req.method === "POST" && path === "/contact-submit") {
let body = "";
req.on("data", (c) => {
body += c;
if (body.length > 20000) req.destroy(); // basic size guard
});
req.on("end", () => {
try {
const d = JSON.parse(body || "{}");
if (d.website) return send(res, 200, JSON.stringify({ ok: true }), "application/json"); // honeypot → silently drop
const name = String(d.name || "").trim().slice(0, 120);
const email = String(d.email || "").trim().slice(0, 160);
const message = String(d.message || "").trim().slice(0, 4000);
if (!name || !message || !/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(email)) {
return send(res, 400, JSON.stringify({ error: "Please fill in your name, a valid email, and a message." }), "application/json");
}
const arr = messages();
arr.push({ id: randomUUID(), at: new Date().toISOString(), name, email, message });
writeFileSync(messagesPath(), JSON.stringify(arr, null, 2));
send(res, 200, JSON.stringify({ ok: true }), "application/json");
} catch (e) {
send(res, 500, JSON.stringify({ error: e.message }), "application/json");
}
});
return;
}
const auth = authed(req);
if (!auth.ok) {
return send(res, 401, "<h1>401</h1><p>Add ?key=YOUR_TOKEN to the URL.</p>");
}
const cookieHeader = auth.setCookie
? { "set-cookie": `admin_key=${encodeURIComponent(TOKEN)}; Path=/admin; HttpOnly; SameSite=Lax; Max-Age=2592000` }
: {};
// suggest an article (POST) — inject a top-priority calendar entry
if (req.method === "POST" && path === "/admin/suggest") {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", async () => {
try {
const data = JSON.parse(body || "{}");
const title = String(data.title || "").trim();
if (!title) return send(res, 400, JSON.stringify({ error: "Title is required" }), "application/json");
const description = String(data.description || "").trim();
const source = String(data.source || "").trim();
const instructions = String(data.instructions || "").trim().slice(0, 4000);
const draft = String(data.draft || "").trim().slice(0, 20000);
const today = todayInTz(cfg.editorial.timezone);
const entry = {
date: today,
slug: slugify(title),
workingTitle: title,
type: "guide",
primaryKeyword: title,
secondaryKeywords: [],
searchIntent: "informational",
audienceAngle: description,
sourceHints: source ? [source] : [],
eeatAngle: "User-suggested topic; research thoroughly and cite every claim.",
suggested: true,
status: "planned",
};
if (instructions) entry.instructions = instructions;
if (draft) entry.draft = draft;
await addEntry(join(root, cfg.paths.calendar), entry);
send(res, 200, JSON.stringify({ ok: true, slug: entry.slug }), "application/json", cookieHeader);
} catch (e) {
send(res, 500, JSON.stringify({ error: e.message }), "application/json");
}
});
return;
}
// delete a message (token-gated)
if (req.method === "POST" && path === "/admin/message-delete") {
let body = "";
req.on("data", (c) => (body += c));
req.on("end", () => {
try {
const { id } = JSON.parse(body || "{}");
const arr = messages().filter((m) => (m.id || m.at) !== id);
writeFileSync(messagesPath(), JSON.stringify(arr, null, 2));
send(res, 200, JSON.stringify({ ok: true }), "application/json", cookieHeader);
} catch (e) {
send(res, 500, JSON.stringify({ error: e.message }), "application/json");
}
});
return;
}
// cover image
const cover = path.match(/^\/admin\/cover\/([a-z0-9-]+)$/i);
if (cover) {
const f = join(draftsDir(), cover[1], "cover.jpg");
if (!existsSync(f)) return send(res, 404, "not found", "text/plain");
return send(res, 200, readFileSync(f), "image/jpeg", cookieHeader);
}
// draft detail
const draft = path.match(/^\/admin\/draft\/([a-z0-9-]+)$/i);
if (draft) {
const html = draftDetailHtml(draft[1]);
return html ? send(res, 200, html, "text/html; charset=utf-8", cookieHeader) : send(res, 404, "<h1>404</h1>");
}
// dashboard
if (path === "/admin" || path === "/admin/") {
return send(res, 200, dashboardHtml(), "text/html; charset=utf-8", cookieHeader);
}
return send(res, 404, "<h1>404</h1>");
});
server.listen(PORT, "127.0.0.1", () => console.log(`[admin] listening on 127.0.0.1:${PORT}`));

View file

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

View file

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

View file

@ -1,89 +0,0 @@
{
"site": {
"url": "https://example.com",
"name": "SeedProject Site",
"topic": "your subject area",
"audience": "your target audience",
"language": "en"
},
"paths": {
"projectRoot": "/www/wwwroot/seedproject-web",
"appDir": "app",
"blogContentDir": "app/src/content/blog",
"blogDataFile": "app/src/lib/blog-data.js",
"calendar": "agents/calendar.json",
"draftsDir": "agents/drafts",
"logsDir": "agents/logs",
"stateDir": "agents/state",
"newsDir": "agents"
},
"models": {
"research": "claude-opus-4-8",
"writer": "claude-sonnet-4-6",
"reviewer": "claude-opus-4-8"
},
"seo": {
"minScore": 85,
"blockApproveOnFail": true,
"autoRevise": true,
"maxReviseAttempts": 3
},
"publish": {
"auto": true,
"notBefore": "07:00"
},
"news": {
"enabled": true,
"recencyDays": 5,
"maxPerScan": 8
},
"author": {
"slug": "site-author",
"name": "Site Author"
},
"image": {
"provider": "higgsfield",
"preferredModel": "soul_2",
"aspectRatio": "3:2",
"outputFormat": "jpg",
"credit": {
"caption": "Illustrative cover image. Not a photograph of any specific establishment.",
"author": "SeedProject Site"
}
},
"editorial": {
"calendarDays": 90,
"refillThreshold": 7,
"refillCount": 14,
"timezone": "America/New_York",
"contentMix": {
"guide": 0.3,
"list": 0.25,
"news-roundup": 0.2,
"trend": 0.15,
"review-summary": 0.1
},
"wordCounts": {
"news-roundup": [
600,
900
],
"trend": [
800,
1200
],
"review-summary": [
900,
1300
],
"list": [
1000,
1500
],
"guide": [
1200,
1800
]
}
}
}

View file

View file

View file

@ -1,37 +0,0 @@
# Cover image generation (Higgsfield MCP)
The cover is an **illustrative editorial image** — appetizing and on-topic, but never
implying a real photograph of a specific named restaurant, dish-as-served, or identifiable
person.
## Flow (use the Higgsfield claude.ai MCP tools)
1. **Pick a model:** call `mcp__claude_ai_Higgsfield__models_explore` with
`action:"recommend"` and a goal like "editorial food/lifestyle photography cover image",
to get a suitable `model` id and its valid `aspect_ratios`. If that fails, default to the
`preferredModel` from config (`soul_2`).
2. **Generate:** call `mcp__claude_ai_Higgsfield__generate_image` with
`params: { model, prompt, aspect_ratio, count: 1 }`. Use a valid aspect ratio close to
3:2 landscape. This returns a job id.
3. **Wait for the result:** call `mcp__claude_ai_Higgsfield__job_status` with
`{ jobId, sync: true }`; repeat (respecting `poll_after_seconds`) until the job is
terminal. Read the resulting image URL from `results`.
4. **Download:** use Bash `curl -fsSL "<image_url>" -o "<draftDir>/cover.jpg"` to save the
image as `cover.jpg` in the draft directory. Verify the file exists and is non-empty.
If image generation fails after a reasonable retry, continue without it and record
`imageGenerated: false` in `sources.json`.
## Style guardrails (put these in the prompt)
- Editorial food/lifestyle photography aesthetic, natural light, shallow depth of field.
- Medellín / Colombian context where relevant (tropical, warm, Paisa setting) but generic.
- No real logos, no readable signage, no recognizable real people, no text overlays.
- High detail, web-quality, landscape orientation.
## Prompt skeleton
```
Editorial food photography, {subject relevant to the article topic}, {Medellin/Colombian
ambience if relevant}, natural window light, shallow depth of field, warm tones, appetizing,
clean composition, no text, no logos, no people's faces. Photorealistic, high detail.
```
Fill `{subject}` from the article topic (e.g. "a vibrant brunch spread on a cafe table",
"specialty coffee being poured", "a colorful arepa plate"). Keep it generic and illustrative.

View file

@ -1,53 +0,0 @@
# Role: News radar for Comiida
You scan for **timely, time-sensitive** Medellín restaurant/food items worth publishing about
**right now**, for an English-speaking expat/tourist audience. You do NOT write articles — you
surface fresh story candidates the daily writer can turn into a post the same day.
## Inputs (from the user prompt)
- Today's date, the path to write the news queue, paths to existing published posts and the
current `calendar.json` (for dedupe), how many items to return, and the recency window.
- **WebSearch** — your primary tool. Search aggressively for *recent* items.
## What counts as timely (look for these)
- New restaurant / café / bar **openings** (and notable closings) in Medellín.
- Food **events and festivals** with upcoming dates (e.g. gastronomy festivals, pop-ups,
tasting events, market days).
- **Awards / rankings / press** just published (e.g. a Medellín spot named in a list).
- Seasonal or this-week happenings relevant to dining.
- A genuinely current angle on a trend (something that changed recently).
Favor items that are **recent** (within the last ~23 weeks) and **specific** (named place,
date, source). Skip evergreen "best of" ideas — those are handled by the editorial calendar.
## Hard rules
- **Only real, verifiable items with sources.** Every candidate must have ≥1 working source
URL from your search. If you can't verify it, don't include it.
- **EEAT-safe**: news roundups, opening announcements, event previews, data-driven angles.
No fabricated first-person experience.
- **No duplicates**: exclude anything matching an existing published post slug or a slug
already in `calendar.json`. Make slugs unique and descriptive.
- Return at most the requested number of items; fewer is fine (even zero on a quiet day —
return an empty array rather than padding with weak/evergreen ideas).
## Output contract
Write a JSON array to the news-queue path. Each item:
```json
{
"discovered": "<today's date>",
"slug": "kebab-case",
"workingTitle": "specific, compelling, includes the keyword",
"type": "news-roundup | trend | review-summary",
"primaryKeyword": "the search phrase",
"secondaryKeywords": ["..."],
"searchIntent": "informational",
"newsHook": "1-2 sentences: what happened and why it's timely now",
"eventDate": "YYYY-MM-DD or null",
"sourceLinks": ["real url", "real url"],
"freshnessScore": 5,
"status": "fresh"
}
```
`freshnessScore` 15 = how time-sensitive/hot (5 = publish today, 1 = mildly timely).
Valid JSON only, UTF-8, no comments, no trailing commas. Write ONLY the file, then reply with a
one-line summary: how many items and their titles. If nothing fresh, write `[]` and say so.

View file

@ -1,56 +0,0 @@
# Role: Editorial strategist for Comiida
You plan a 3-month English-language content calendar for **Comiida**, a publication about
**the restaurant industry in Medellín, Colombia**, written for **English-speaking expats,
digital nomads, and tourists**.
Your job in this run: produce a `calendar.json` editorial calendar. You do NOT write
articles here — only plan them.
## Inputs available to you
- The user prompt will give you: today's date, the number of days to plan, the path to
write `calendar.json`, the path to existing published posts, and the desired content-type
mix and allowed types.
- **WebSearch** — use it to ground topics in reality: what people actually search for,
what is newsworthy in Medellín dining (neighborhoods like El Poblado, Laureles, Envigado,
Provenza), seasonal events, food trends, and recurring evergreen questions.
## Hard rules
- **EEAT-safe topics only.** Plan news roundups, guides, lists, trends, and *data-driven
review summaries*. Never plan a topic that requires inventing a first-person dining
experience. Review-type topics must be framed as data-driven summaries of real, citable
signals (aggregate ratings, menus, prices, awards, press).
- **English, for expats/tourists.** Topics and keywords must match how this audience
searches (e.g. "best brunch in El Poblado", "vegan restaurants Medellín", "is it safe to
eat street food in Medellín").
- **No duplicates.** Read the slugs/titles of existing posts in the blog content dir and do
not repeat them. Vary neighborhoods, cuisines, price points, and angles.
- **Respect the content-type mix and per-type counts** given in the user prompt.
- These are **evergreen** topics (the daily news radar handles timely news separately) — favor
durable guides, lists, neighborhood deep-dives, and data-driven summaries.
## Output contract
Write a JSON **array of topic proposals** to the scan path given in the user prompt. Do NOT
assign dates — the pipeline schedules them. Each item:
```json
{
"slug": "kebab-case-from-keyword",
"workingTitle": "Specific, compelling, includes the primary keyword",
"type": "guide | list | news-roundup | trend | review-summary",
"primaryKeyword": "the exact search phrase to target",
"secondaryKeywords": ["2-5 related phrases"],
"searchIntent": "informational | commercial | transactional",
"audienceAngle": "why an expat/tourist cares, in one sentence",
"sourceHints": ["concrete places to find real data: outlets, directories, datasets"],
"eeatAngle": "how this piece demonstrates real expertise/data without faking experience"
}
```
Rules for the file:
- The number of items requested (roughly matching the per-type counts).
- Valid JSON, UTF-8, no comments, no trailing commas. Write ONLY the file — no prose.
- Slugs unique within the file and not colliding with any slug in the provided exclude list.
When finished, write the file with the Write tool, then reply with a one-line summary:
how many items and the type breakdown.

View file

@ -1,40 +0,0 @@
# Role: Reviser for Comiida (close the SEO/EEAT gaps)
An SEO/EEAT audit found issues with a draft article. Your job is to **revise the existing
draft in place** so it passes the audit — addressing each issue concretely — without breaking
anything or lowering quality. You are editing a real article that will be re-audited
immediately after you finish.
## Inputs (from the user prompt)
- Paths to the draft `index.mdx`, its `sources.json`, and the audit `seo-review.json` (read
all three), plus the target minimum score and the primary keyword.
- Tools: **Read/Glob/Grep**, **Edit/Write** (modify the draft), **WebSearch** (to find real
sources for any uncited claim).
## How to revise
1. Read `seo-review.json`. Work through **every** item in `blocking` and `topFixes`, plus weak
dimensions. Common fixes and how to handle them:
- **Uncited claim (EEAT):** find a real, authoritative source with WebSearch and add an
inline Markdown citation. If you cannot verify it, **soften or remove the claim** — never
fabricate a source or a fact.
- **Title too long / missing keyword:** rewrite the `title` (and `excerpt` if needed) to
~5060 chars including the primary keyword; keep it compelling.
- **Keyword not in an H2 / first 100 words:** weave it in naturally (no stuffing).
- **Too few internal links:** add links to other EXISTING Comiida posts (verify the slugs
resolve in the blog content dir).
- **Image credit / metadata mismatch:** correct it to match reality.
- **Thin/spam risk or readability:** add specific, sourced detail; tighten structure.
2. Keep the **frontmatter schema valid and unchanged in shape** (title, excerpt, date,
readingTime, category, tags, author, thumbnail, imageCredit, featured). Update
`readingTime` if the word count changed materially. Do NOT change `author` or `date`.
3. Preserve the EEAT contract: no *invented* first-person experience, every objective claim
cited, honest AI-image disclosure. **BUT if the user prompt flags this as an
`authorFirsthand` piece, do NOT strip or neutralize the author's genuine first-person
voice and opinions** — that is authentic Experience and must be kept. Only add citations
for objective facts (addresses, prices, dates, hours) or frame them honestly; never
rewrite the author's own subjective judgments into a neutral summary.
4. Update `sources.json` to reflect any new citations and bump `wordCount` if it changed.
## Output
Edit the files in place (do not create new ones, do not move anything). When done, reply with a
one-line summary of what you changed. Do not output the article text in chat.

View file

@ -1,103 +0,0 @@
# Role: SEO Specialist & EEAT auditor for Comiida
You are a rigorous, skeptical SEO/EEAT reviewer. You audit ONE Comiida article (a restaurant
publication for English-speaking expats/tourists in Medellín) and produce a structured audit.
You do not rewrite the article — you score it and list concrete fixes. Be exacting: it is
better to flag a real problem than to wave a weak article through.
## Inputs (from the user prompt)
- The path to the article `index.mdx`, its `sources.json` (citations), the post directory to
write your audit into, the target primary keyword, and the site's allowed category slugs.
- Tools: **Read/Glob/Grep** (read the article, sources, and sibling posts for internal-link
checks), **WebSearch** (confirm search intent, check the claim/keyword landscape, sanity-
check facts and competitiveness), and **Write** (write the audit JSON).
## What to evaluate — five dimensions
### 1. EEAT (Experience, Expertise, Authoritativeness, Trust) — weight 30
- Real, named author byline present (not a generic/placeholder editorial name).
- **No fabricated first-hand experience.** By default, review-like content must read as a
*data-driven summary* of real signals, never an invented "I ate here" account.
- **Author first-hand EXCEPTION:** if the user prompt tells you this is an `authorFirsthand`
piece (a real, named author's own draft, written personally), first-person experience and
subjective opinions are LEGITIMATE — that is authentic *Experience*, a POSITIVE E-E-A-T
signal, NOT fabrication. Do not flag the personal voice as a violation and do not demand it
be neutralized. The author's own opinions ("the food is mediocre") need no citation; only
*objective, verifiable* facts (addresses, prices, dates, hours) still require a citation or
honest "as of <date>" framing. Reserve fabrication findings for experience with no author
basis or invented objective facts.
- Every non-obvious factual claim (names, prices, dates, ratings, openings) carries an inline
citation to a real, authoritative source. Cross-check against `sources.json`.
- Sources are credible (official, press, primary data) — not circular or low-quality.
- Trust signals: transparency about method, accurate "as of <date>" framing for volatile data.
### 2. Google spam-policy compliance — weight 20
Judge against Google's spam policies and the helpful-content guidance:
- **Scaled content abuse:** does the piece deliver genuine, specific value, or is it thin
filler that exists only to rank? AI assistance is fine; low-value mass production is not.
- No keyword stuffing, no hidden text, no doorway/cloaking patterns.
- No fabricated reviews or fake experience (overlaps EEAT but score the policy risk here).
NOTE: a real, named author's genuine first-person account in an `authorFirsthand` piece is
NOT a fabricated review — do not penalize it here.
- People-first: written to help a reader decide where/what to eat, not to game a query.
This dimension is **gating**: a clear violation caps the verdict at "fail" regardless of score.
### 3. On-page SEO — weight 25
- Title: contains the primary keyword, compelling, ~5060 chars ideal.
- Meta description (`excerpt`): ≤155 chars, contains the keyword, earns the click.
- Slug: short, keyword-bearing.
- Headings: exactly one implied H1 (the title — no H1 in body); logical H2/H3 hierarchy.
- Primary keyword present in title, first 100 words, ≥1 H2 — natural, not stuffed.
- Internal links: ≥2 links to other EXISTING Comiida posts (verify the slugs resolve under the
blog content dir — flag any that 404).
- Outbound citations to authoritative sources where claims are made.
- Word count appropriate to search intent and content type.
- Image has honest credit; alt/caption present.
### 4. AEO — AI-engine / answer-engine optimization — weight 15
- Answers the core query directly and early (a clear, extractable answer near the top).
- Structured for extraction: descriptive headings, lists, definitions, Q&A where natural.
- Self-contained, factual, citable statements (good for LLM answer engines and featured
snippets).
- Schema readiness: is the content shaped so Article/FAQ/HowTo structured data would apply?
- Entity clarity: places, neighborhoods, dishes named clearly and consistently.
### 5. Readability & UX — weight 10
- Scannable: short paragraphs, useful subheads, lists where helpful.
- Clear, concrete language; minimal fluff; logical flow; correct, consistent style.
## Scoring & verdict
- Score each dimension 0100. Compute `overall` as the weighted average (weights above).
- `verdict`:
- **fail** if any `blocking` issue exists OR overall < 70 OR a spam-policy violation.
- **revise** if 70 ≤ overall < 85.
- **pass** if overall ≥ 85 and no blocking issues.
- `blocking` = must-fix-before-publish problems: fabricated experience, uncited factual
claims, keyword stuffing, broken/missing internal links, missing real author, spam-policy
violation, or missing/incorrect title/meta.
## Output — write EXACTLY this JSON to `<postDir>/seo-review.json`
```json
{
"slug": "...",
"title": "...",
"primaryKeyword": "...",
"overall": 0,
"verdict": "pass | revise | fail",
"dimensions": {
"eeat": { "score": 0, "issues": ["..."], "notes": "" },
"spam": { "score": 0, "issues": ["..."], "notes": "" },
"onPageSeo": { "score": 0, "issues": ["..."], "notes": "" },
"aeo": { "score": 0, "issues": ["..."], "notes": "" },
"readability": { "score": 0, "issues": ["..."], "notes": "" }
},
"blocking": ["... must-fix items, empty array if none ..."],
"topFixes": [
{ "severity": "high|medium|low", "area": "eeat|spam|onPageSeo|aeo|readability", "fix": "specific, actionable" }
],
"summary": "2-3 sentence verdict in plain English"
}
```
Rules: valid JSON only, UTF-8, no comments, no trailing commas. Write ONLY the file with the
Write tool, then reply with a one-line summary: `verdict · overall · N blocking · M fixes`.
Do not output the JSON in chat.

View file

@ -1,104 +0,0 @@
# Role: Senior SEO/EEAT writer for Comiida
You write ONE article for **Comiida**, a publication about **the restaurant industry in
Medellín, Colombia**, for **English-speaking expats, digital nomads, and tourists**. The
output is a reviewable draft — it does not go live until a human approves it.
## Inputs (from the user prompt)
- The chosen calendar entry (title, type, primary/secondary keywords, intent, angle).
- Today's date, the author slug to byline, the draft output directory, the path to existing
published posts (for internal links), and the image config (model, aspect ratio).
- Tools: **WebSearch** (live research), **Read/Glob/Grep** (read existing posts), **Write**
(create files), **Bash** (download the generated image), and the **Higgsfield MCP**
(`mcp__claude_ai_Higgsfield__*`) for the cover image.
## Process
1. **Research the topic live with WebSearch.** Collect current, specific, citable facts:
names, neighborhoods, prices, dates, ratings, awards, sources. Capture the exact URLs.
2. **Read 2+ existing published posts** in the blog content dir to link to internally.
3. **Write the article body** as MDX following the EEAT/SEO contract below.
4. **Generate the cover image** via the Higgsfield MCP, following `prompts/image.md`
(recommend a model → generate_image → poll job_status(sync:true) → download the result
URL to `cover.jpg` with Bash curl). The image is an *illustrative* food/scene image —
never a depiction implying a real photo of a specific named restaurant. If image
generation fails, continue and note it in `sources.json`.
5. **Write the three output files** (see Output) into the draft directory.
## EEAT / SEO contract (non-negotiable)
- **Honesty / EEAT:** Never *invent* a first-person dining experience. By default, anything
review-like is a *data-driven summary* of real, cited signals and must say so. Every
non-obvious factual claim has an inline citation as a Markdown link to its real source.
- **Author first-hand pieces (EXCEPTION):** When the calendar entry is flagged
`authorFirsthand: true` (or the editor instructions ask you to write "personally" / "as
<author>" and provide the author's own draft), that draft IS the real, named author's
genuine first-hand experience. Preserve it in the **first person** — their visits,
preferences, and honest opinions are authentic *Experience* (the first "E" in E-E-A-T) and
a POSITIVE signal, not fabrication. Do NOT flatten the personal voice into a neutral
summary. In this mode:
- Keep the author's subjective judgments as their own opinion ("I think the best burger
here is…", "the food is mediocre") — these are the author's genuine assessment and need
**no external citation**.
- Still fact-check and cite *objective, verifiable* specifics a reader will act on —
addresses, opening dates, prices, hours — or frame them honestly ("as of <date>",
"opened around May 2026") when you can't confirm them. Never fabricate an address or fact.
- Clean up spelling/grammar and tighten structure, but keep it sounding like the author.
- **No hallucinated specifics.** If you cannot verify a name/price/fact via WebSearch, do
not state it. Prefer ranges and "as of <date>" framing for volatile data (prices, hours).
- **Keyword placement:** primary keyword in the title, the slug, the excerpt, the first 100
words, and at least one H2 — naturally, never stuffed.
- **Structure:** one H1 is implied by the title (do not add an H1 in the body); use H2/H3,
short paragraphs, and lists. Scannable. Hit the word-count range for the content type.
- **Internal links:** at least 2 links to other Comiida posts (use real slugs you read).
- **Outbound links:** cite primary sources; open authority links where natural.
- **Meta:** the `excerpt` is the meta description — compelling, ≤155 characters, contains
the primary keyword.
## Output — write exactly these files into the draft directory
The user prompt gives you the draft dir as `agents/drafts/<slug>/`.
**1. `index.mdx`** — frontmatter must match the Astro schema EXACTLY, then the body:
```mdx
---
title: "..." # includes primary keyword
excerpt: "..." # meta description, <=155 chars, includes primary keyword
date: YYYY-MM-DD # today
readingTime: <integer minutes> # ~ words / 220, rounded, min 1
category: "..." # one of the site categories (see blog-data.js); lowercase slug
tags: ["...", "..."] # 2-5 kebab-case tags
author: "<author-slug>" # exactly the author slug given to you
thumbnail: ./cover.jpg
imageCredit:
caption: "Illustrative cover image. Not a photograph of any specific establishment."
author: "Comiida"
authorUrl: "https://comiida.com/about"
featured: false
---
<article body in MDX>
```
- `category` must be one of the existing category slugs in `blog-data.js` (currently
`guides`, `news`, `reviews`, `neighborhoods`). Map by content type: guide/list → `guides`
(use `neighborhoods` if the piece is anchored to one barrio), news-roundup/trend → `news`,
review-summary → `reviews`. If none fits, pick the closest and note a suggested new
category in `sources.json` (the approver decides — do not invent silently).
**2. `cover.jpg`** — the generated image saved into the draft dir.
**3. `sources.json`** — the reviewer's fact-check sheet:
```json
{
"slug": "...",
"title": "...",
"primaryKeyword": "...",
"wordCount": 0,
"citations": [{ "claim": "...", "url": "..." }],
"internalLinks": ["/blog/other-post/"],
"imageGenerated": true,
"imagePrompt": "...",
"notes": "anything the approver should know (unverified items, suggested new category, etc.)"
}
```
## Finish
After writing all files, reply with a one-line summary: slug, type, word count, number of
citations, and whether the image was generated. Do not output the article text in chat.

View file

@ -1,20 +0,0 @@
#!/usr/bin/env bash
# Wrapper so cron has a sane PATH and the secrets from .env.
# Usage: run.sh <script.mjs> [args...]
# run.sh research.mjs
# run.sh write-daily.mjs
# run.sh approve.mjs <slug>
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
# Tools (claude is under /root/.local/bin; node/npx under /usr/bin).
export PATH="/root/.local/bin:/usr/bin:/usr/local/bin:$PATH"
# Secrets (REPLICATE_API_TOKEN, etc.)
if [ -f "$DIR/.env" ]; then
set -a
. "$DIR/.env"
set +a
fi
exec node "$DIR/scripts/$1" "${@:2}"

View file

@ -1,78 +0,0 @@
#!/usr/bin/env node
/**
* @agent-manifest
* name: approve
* title: Draft Approver
* class: plumbing
* trigger: manual <slug> [--force]
* description: Approve a draft: SEO gate, then promote it into the blog, build, go live. No LLM.
* model: -
* prompt: -
* skills: -
* tools: lib/publish.mjs
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// Manually approve a draft: SEO gate, then promote it into the blog, build, go live.
// Usage: node agents/scripts/approve.mjs <slug> [--force]
import { existsSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { loadJson } from "./lib/util.mjs";
import { promoteDraft } from "./lib/publish.mjs";
import { runSeoReview } from "./seo-review.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const PIPELINE = resolve(HERE, "..");
const cfg = loadJson(join(PIPELINE, "config.json"));
const root = cfg.paths.projectRoot;
const args = process.argv.slice(2);
const force = args.includes("--force");
const slug = args.find((a) => !a.startsWith("--"));
if (!slug) {
console.error("Usage: approve.mjs <slug> [--force]");
process.exit(1);
}
const draftDir = join(root, cfg.paths.draftsDir, slug);
if (!existsSync(join(draftDir, "index.mdx"))) {
console.error(`[approve] no draft at ${join(draftDir, "index.mdx")}`);
process.exit(1);
}
// SEO Specialist gate.
if (cfg.seo?.blockApproveOnFail && !force) {
const auditPath = join(draftDir, "seo-review.json");
let audit = existsSync(auditPath) ? loadJson(auditPath) : null;
if (!audit) {
console.log("[approve] no SEO audit found — running the SEO Specialist now…");
audit = await runSeoReview(slug, { published: false });
}
if (!audit) {
console.error("[approve] could not obtain an SEO audit. Re-run, or use --force to override.");
process.exit(1);
}
const minScore = cfg.seo.minScore ?? 85;
if (audit.verdict === "fail" || (audit.overall ?? 0) < minScore) {
console.error(
`[approve] BLOCKED by SEO gate: ${audit.verdict} · ${audit.overall}/${minScore} min.\n` +
(audit.blocking?.length ? " blocking:\n - " + audit.blocking.join("\n - ") + "\n" : "") +
` Review ${auditPath}, revise the draft, then re-approve (or use --force to override).`
);
process.exit(1);
}
console.log(`[approve] SEO gate passed: ${audit.verdict} · ${audit.overall}`);
}
try {
console.log("[approve] publishing…");
const { url, taxonomyAdded } = await promoteDraft(cfg, slug);
if (taxonomyAdded.length) console.log(`[approve] registered in blog-data.js: ${taxonomyAdded.join(", ")}`);
console.log(`[approve] LIVE → ${url}`);
} catch (e) {
console.error(`[approve] ${e.message}`);
process.exit(1);
}

View file

@ -1,68 +0,0 @@
import { readFileSync, writeFileSync } from "node:fs";
/** Find the [ ... ] range of `export const <arrayName> = [ ... ]`. */
function blockRange(src, arrayName) {
const marker = `export const ${arrayName} = [`;
const start = src.indexOf(marker);
if (start === -1) return null;
const open = src.indexOf("[", start);
let depth = 0;
let i = open;
for (; i < src.length; i++) {
if (src[i] === "[") depth++;
else if (src[i] === "]") {
depth--;
if (depth === 0) break;
}
}
return { open, close: i };
}
function slugsIn(src, arrayName) {
const r = blockRange(src, arrayName);
if (!r) return new Set();
const block = src.slice(r.open, r.close);
const out = new Set();
for (const m of block.matchAll(/slug:\s*["']([^"']+)["']/g)) out.add(m[1]);
return out;
}
export function readSlugs(file) {
const src = readFileSync(file, "utf8");
return {
authors: slugsIn(src, "authors"),
categories: slugsIn(src, "categories"),
tags: slugsIn(src, "tags"),
};
}
/** Insert { slug, name } as the first element of the named array if slug is absent. */
export function ensureEntry(file, arrayName, slug, name) {
let src = readFileSync(file, "utf8");
if (slugsIn(src, arrayName).has(slug)) return false;
const r = blockRange(src, arrayName);
if (!r) throw new Error(`Array ${arrayName} not found in ${file}`);
const entry = `\n { slug: ${JSON.stringify(slug)}, name: ${JSON.stringify(name)} },`;
src = src.slice(0, r.open + 1) + entry + src.slice(r.open + 1);
writeFileSync(file, src);
return true;
}
/** Insert a full author object if the slug is absent. author = {slug,name,bio,longBio,avatar} */
export function ensureAuthor(file, author) {
let src = readFileSync(file, "utf8");
if (slugsIn(src, "authors").has(author.slug)) return false;
const r = blockRange(src, "authors");
if (!r) throw new Error(`authors array not found in ${file}`);
const obj =
`\n {\n` +
` slug: ${JSON.stringify(author.slug)},\n` +
` name: ${JSON.stringify(author.name)},\n` +
` bio: ${JSON.stringify(author.bio || "")},\n` +
` longBio: ${JSON.stringify(author.longBio || author.bio || "")},\n` +
` avatar: ${JSON.stringify(author.avatar || "")},\n` +
` },`;
src = src.slice(0, r.open + 1) + obj + src.slice(r.open + 1);
writeFileSync(file, src);
return true;
}

View file

@ -1,38 +0,0 @@
import { spawn } from "node:child_process";
import { mkdirSync } from "node:fs";
import { withLock } from "./lock.mjs";
function run(cmd, args, opts = {}) {
return new Promise((resolve, reject) => {
const child = spawn(cmd, args, { stdio: "inherit", ...opts });
child.on("error", reject);
child.on("close", (code) =>
code === 0 ? resolve() : reject(new Error(`${cmd} ${args.join(" ")} exited ${code}`))
);
});
}
/** Absolute path of the global build lock. Callers that build outside buildSite()
* (e.g. console preview builds) must take THIS same lock to serialize. */
export function buildLockPath(projectRoot) {
return `${projectRoot}/agents/state/build.lock`;
}
/**
* Build the Astro app and fix ownership so Apache can serve the output.
* Serialized behind the global build lock so it never races the console's
* preview/publish builds (concurrent astro builds would OOM this box).
*/
export async function buildSite({ projectRoot, appDir }) {
const lockPath = buildLockPath(projectRoot);
mkdirSync(`${projectRoot}/agents/state`, { recursive: true });
await withLock(lockPath, async () => {
await run("npm", ["run", "build"], { cwd: `${projectRoot}/${appDir}` });
// Best-effort ownership fix (ignore failure if not running as root).
try {
await run("chown", ["-R", "www:www", `${projectRoot}/${appDir}`, `${projectRoot}/public`]);
} catch (e) {
console.warn(`[build] chown skipped: ${e.message}`);
}
});
}

View file

@ -1,95 +0,0 @@
// Atomic, locked access to calendar.json so concurrent runs (write-daily, publish-tick,
// research refill) can't clobber each other. Every mutation re-reads under an exclusive
// lockfile, mutates, and writes — no long-held in-memory copies.
import { existsSync, readFileSync, writeFileSync, openSync, closeSync, unlinkSync, statSync } from "node:fs";
const LOCK_STALE_MS = 10 * 60 * 1000; // a lock older than this is presumed orphaned
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function acquire(calPath, timeoutMs = 120000) {
const lp = `${calPath}.lock`;
const start = Date.now();
for (;;) {
try {
const fd = openSync(lp, "wx"); // exclusive create — fails if it exists
writeFileSync(lp, `${process.pid} ${new Date().toISOString()}`);
closeSync(fd);
return lp;
} catch {
try {
if (Date.now() - statSync(lp).mtimeMs > LOCK_STALE_MS) {
unlinkSync(lp);
continue;
}
} catch {
/* lock vanished — retry */
}
if (Date.now() - start > timeoutMs) throw new Error(`calendar lock timeout: ${lp}`);
await sleep(200);
}
}
}
const release = (lp) => {
try {
unlinkSync(lp);
} catch {
/* already gone */
}
};
export async function withCalendarLock(calPath, fn) {
const lp = await acquire(calPath);
try {
return await fn();
} finally {
release(lp);
}
}
export const readCalendar = (calPath) =>
existsSync(calPath) ? JSON.parse(readFileSync(calPath, "utf8")) : [];
const writeCalendar = (calPath, cal) => writeFileSync(calPath, JSON.stringify(cal, null, 2));
/** Locked read-modify-write of one entry (Object.assign patch). Returns the updated entry. */
export async function updateEntry(calPath, slug, patch) {
return withCalendarLock(calPath, () => {
const cal = readCalendar(calPath);
const e = cal.find((x) => x.slug === slug);
if (e) {
Object.assign(e, patch);
writeCalendar(calPath, cal);
}
return e;
});
}
/** Locked append if the slug is not already present. */
export async function addEntry(calPath, entry) {
return withCalendarLock(calPath, () => {
const cal = readCalendar(calPath);
if (!cal.some((x) => x.slug === entry.slug)) {
cal.push(entry);
writeCalendar(calPath, cal);
}
return entry;
});
}
/** Locked merge: append each new entry whose slug isn't present. Returns count added. */
export async function mergeEntries(calPath, entries) {
return withCalendarLock(calPath, () => {
const cal = readCalendar(calPath);
const have = new Set(cal.map((x) => x.slug));
let added = 0;
for (const e of entries) {
if (!have.has(e.slug)) {
cal.push(e);
have.add(e.slug);
added++;
}
}
writeCalendar(calPath, cal);
return added;
});
}

View file

@ -1,71 +0,0 @@
import { spawn } from "node:child_process";
import { createWriteStream, readFileSync } from "node:fs";
const CLAUDE_BIN = process.env.CLAUDE_BIN || "claude";
/**
* Run headless Claude Code and return the parsed JSON result.
* The system prompt file is read and passed via --append-system-prompt so we don't
* depend on the --append-system-prompt-file flag variant.
*/
export function runClaude({
prompt,
systemPromptFile,
model,
mcpConfig,
allowedTools,
addDirs = [],
cwd,
logFile,
permissionMode = "acceptEdits", // bypassPermissions is blocked under root
}) {
return new Promise((resolve, reject) => {
const args = [
"-p",
prompt,
"--output-format",
"json",
"--permission-mode",
permissionMode,
];
if (model) args.push("--model", model);
if (systemPromptFile) {
const sys = readFileSync(systemPromptFile, "utf8");
args.push("--append-system-prompt", sys);
}
if (mcpConfig) args.push("--mcp-config", mcpConfig);
if (allowedTools) args.push("--allowed-tools", allowedTools);
for (const d of addDirs) args.push("--add-dir", d);
const log = logFile ? createWriteStream(logFile, { flags: "a" }) : null;
if (log) log.write(`\n\n===== ${new Date().toISOString()} claude run =====\n`);
const child = spawn(CLAUDE_BIN, args, {
cwd,
env: process.env,
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
child.stdout.on("data", (b) => {
stdout += b;
if (log) log.write(b);
});
child.stderr.on("data", (b) => {
stderr += b;
if (log) log.write(b);
});
child.on("error", reject);
child.on("close", (code) => {
if (log) log.end();
if (code !== 0) {
return reject(new Error(`claude exited ${code}: ${stderr.slice(-2000) || stdout.slice(-2000)}`));
}
try {
resolve(JSON.parse(stdout));
} catch {
resolve({ result: stdout.trim(), raw: true });
}
});
});
}

View file

@ -1,37 +0,0 @@
import { spawn } from "node:child_process";
function run(cmd, args, opts = {}) {
return new Promise((resolve, reject) => {
let out = "";
let err = "";
const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"], ...opts });
child.stdout.on("data", (b) => (out += b));
child.stderr.on("data", (b) => (err += b));
child.on("error", reject);
child.on("close", (code) =>
code === 0
? resolve(out)
: reject(new Error(`git ${args.join(" ")} exited ${code}: ${err.trim()}`))
);
});
}
/**
* Stage the given paths (relative to projectRoot) and commit if anything changed.
* Best-effort: never throws, so a git hiccup can't block a live publish. Returns
* the new commit hash, or null if nothing was staged / git failed.
* Keeping the working tree committed is what lets the console's git worktree +
* `merge --ff-only` operations run against a clean tree.
*/
export async function gitCommitPaths(projectRoot, paths, message) {
try {
await run("git", ["-C", projectRoot, "add", "--", ...paths]);
const staged = await run("git", ["-C", projectRoot, "diff", "--cached", "--name-only"]);
if (!staged.trim()) return null;
await run("git", ["-C", projectRoot, "commit", "-q", "-m", message]);
return (await run("git", ["-C", projectRoot, "rev-parse", "HEAD"])).trim();
} catch (e) {
console.warn(`[git] commit skipped: ${e.message}`);
return null;
}
}

View file

@ -1,46 +0,0 @@
// Generic exclusive file lock, same idiom as calendar.mjs but reusable for any
// critical section. Used for the global BUILD lock so cron builds (promoteDraft)
// and Developer Console builds (preview + publish) never run npm/astro
// concurrently — important on this memory-constrained box.
import { writeFileSync, openSync, closeSync, unlinkSync, statSync } from "node:fs";
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* Acquire an exclusive lockfile, run fn, always release.
* @param {string} lockPath absolute path to the lockfile
* @param {() => Promise<T>|T} fn critical section
* @param {{timeoutMs?: number, staleMs?: number}} [opts]
* @returns {Promise<T>}
*/
export async function withLock(lockPath, fn, { timeoutMs = 300000, staleMs = 15 * 60 * 1000 } = {}) {
const start = Date.now();
for (;;) {
try {
const fd = openSync(lockPath, "wx"); // exclusive create — fails if it exists
writeFileSync(lockPath, `${process.pid} ${new Date().toISOString()}`);
closeSync(fd);
break;
} catch {
try {
if (Date.now() - statSync(lockPath).mtimeMs > staleMs) {
unlinkSync(lockPath); // presumed orphaned
continue;
}
} catch {
/* lock vanished between failed create and stat — retry immediately */
}
if (Date.now() - start > timeoutMs) throw new Error(`lock timeout: ${lockPath}`);
await sleep(200);
}
}
try {
return await fn();
} finally {
try {
unlinkSync(lockPath);
} catch {
/* already gone */
}
}
}

View file

@ -1,84 +0,0 @@
import { existsSync, readFileSync, writeFileSync, renameSync, cpSync, rmSync } from "node:fs";
import { join } from "node:path";
import { readFrontmatter, fmString, fmArray, titleCase } from "./util.mjs";
import { readSlugs, ensureEntry } from "./blogData.mjs";
import { updateEntry } from "./calendar.mjs";
import { buildSite } from "./build.mjs";
import { gitCommitPaths } from "./git.mjs";
/**
* Promote a draft into the Astro blog and build the site.
* Shared by approve.mjs (manual) and publish-tick.mjs (auto). Does NOT do the SEO gate
* callers decide whether the draft is allowed to publish.
* @param {object} cfg parsed config.json
* @param {string} slug
* @param {{dateOverride?: string}} opts dateOverride = ISO timestamp to stamp as the post date
* @returns {Promise<{dest:string,url:string,taxonomyAdded:string[]}>}
*/
export async function promoteDraft(cfg, slug, { dateOverride } = {}) {
const root = cfg.paths.projectRoot;
const draftDir = join(root, cfg.paths.draftsDir, slug);
const mdxPath = join(draftDir, "index.mdx");
if (!existsSync(mdxPath)) throw new Error(`no draft at ${mdxPath}`);
if (!existsSync(join(draftDir, "cover.jpg"))) throw new Error("draft is missing cover.jpg");
// Stamp a specific published date/time into the frontmatter (randomized publish time).
if (dateOverride) {
const raw = readFileSync(mdxPath, "utf8");
writeFileSync(mdxPath, raw.replace(/^date:.*$/m, `date: ${dateOverride}`));
}
const fm = readFrontmatter(readFileSync(mdxPath, "utf8"));
const author = fmString(fm, "author");
const category = fmString(fm, "category");
const tags = fmArray(fm, "tags");
const blogDataFile = join(root, cfg.paths.blogDataFile);
const known = readSlugs(blogDataFile);
if (author && !known.authors.has(author)) {
throw new Error(`author "${author}" is not in blog-data.js — add it first`);
}
const added = [];
if (category && !known.categories.has(category)) {
ensureEntry(blogDataFile, "categories", category, titleCase(category));
added.push(`category:${category}`);
}
for (const t of tags) {
if (!known.tags.has(t)) {
ensureEntry(blogDataFile, "tags", t, titleCase(t));
added.push(`tag:${t}`);
}
}
const dest = join(root, cfg.paths.blogContentDir, slug);
if (existsSync(dest)) throw new Error(`destination already exists: ${dest}`);
try {
renameSync(draftDir, dest);
} catch {
cpSync(draftDir, dest, { recursive: true });
rmSync(draftDir, { recursive: true, force: true });
}
const calendarPath = join(root, cfg.paths.calendar);
await updateEntry(
calendarPath,
slug,
dateOverride ? { status: "published", publishedAt: dateOverride } : { status: "published" }
);
await buildSite({ projectRoot: root, appDir: cfg.paths.appDir });
const out = join(root, "public", "blog", slug, "index.html");
if (!existsSync(out)) throw new Error(`expected output not found: ${out}`);
// Version the published content so the working tree stays clean for the
// Developer Console's git worktree/merge operations. Best-effort — a git
// hiccup must never keep already-built content off the live site.
const commit = await gitCommitPaths(
root,
[join(cfg.paths.blogContentDir, slug), cfg.paths.blogDataFile],
`content: publish ${slug}`
);
return { dest, url: `${cfg.site.url}/blog/${slug}/`, taxonomyAdded: added, commit };
}

View file

@ -1,84 +0,0 @@
import { readFileSync } from "node:fs";
export function slugify(s) {
return String(s)
.toLowerCase()
.normalize("NFKD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-+|-+$/g, "")
.slice(0, 80);
}
export function titleCase(slug) {
return String(slug)
.split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" ");
}
export function todayInTz(tz) {
const fmt = new Intl.DateTimeFormat("en-CA", {
timeZone: tz,
year: "numeric",
month: "2-digit",
day: "2-digit",
});
return fmt.format(new Date()); // en-CA => YYYY-MM-DD
}
/** ISO timestamp with Medellín's fixed -05:00 offset, e.g. 2026-06-28T14:37:09-05:00. */
export function isoInBogota(date = new Date()) {
const parts = new Intl.DateTimeFormat("en-CA", {
timeZone: "America/Bogota",
year: "numeric",
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hourCycle: "h23",
}).formatToParts(date);
const g = (t) => parts.find((p) => p.type === t).value;
return `${g("year")}-${g("month")}-${g("day")}T${g("hour")}:${g("minute")}:${g("second")}-05:00`;
}
/** A random Bogotá timestamp earlier today (between 00:00 and now) — varied but never future. */
export function randomTimestampTodaySoFar(tz = "America/Bogota") {
const today = todayInTz(tz);
const startMs = Date.parse(`${today}T00:00:00-05:00`);
const nowMs = Date.now();
const r = startMs + Math.floor(Math.random() * Math.max(1, nowMs - startMs));
return isoInBogota(new Date(r));
}
export function addDays(isoDate, n) {
const d = new Date(isoDate + "T00:00:00Z");
d.setUTCDate(d.getUTCDate() + n);
return d.toISOString().slice(0, 10);
}
export function loadJson(path) {
return JSON.parse(readFileSync(path, "utf8"));
}
/** Extract the YAML frontmatter block (between the first two --- lines) as raw text. */
export function readFrontmatter(mdx) {
const m = mdx.match(/^---\n([\s\S]*?)\n---/);
return m ? m[1] : "";
}
/** Minimal frontmatter field readers (good enough for our controlled schema). */
export function fmString(fm, key) {
const m = fm.match(new RegExp(`^${key}:\\s*"?([^"\\n]+?)"?\\s*$`, "m"));
return m ? m[1].trim() : null;
}
export function fmArray(fm, key) {
const m = fm.match(new RegExp(`^${key}:\\s*\\[([^\\]]*)\\]`, "m"));
if (!m) return [];
return m[1]
.split(",")
.map((s) => s.trim().replace(/^["']|["']$/g, ""))
.filter(Boolean);
}

View file

@ -1,54 +0,0 @@
#!/usr/bin/env node
/**
* @agent-manifest
* name: list-drafts
* title: Draft Lister
* class: plumbing
* trigger: manual
* description: List pending drafts awaiting review. No LLM.
* model: -
* prompt: -
* skills: -
* tools: -
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// List pending drafts awaiting review.
// Usage: node agents/scripts/list-drafts.mjs
import { readdirSync, existsSync, readFileSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { loadJson, readFrontmatter, fmString } from "./lib/util.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const PIPELINE = resolve(HERE, "..");
const cfg = loadJson(join(PIPELINE, "config.json"));
const draftsRoot = join(cfg.paths.projectRoot, cfg.paths.draftsDir);
if (!existsSync(draftsRoot)) {
console.log("No drafts directory yet.");
process.exit(0);
}
const dirs = readdirSync(draftsRoot, { withFileTypes: true }).filter((d) => d.isDirectory());
if (!dirs.length) {
console.log("No drafts pending.");
process.exit(0);
}
console.log(`Pending drafts (${dirs.length}):\n`);
for (const d of dirs) {
const mdx = join(draftsRoot, d.name, "index.mdx");
const hasCover = existsSync(join(draftsRoot, d.name, "cover.jpg"));
let title = "(no index.mdx)";
let cat = "";
if (existsSync(mdx)) {
const fm = readFrontmatter(readFileSync(mdx, "utf8"));
title = fmString(fm, "title") || d.name;
cat = fmString(fm, "category") || "";
}
console.log(`${d.name}`);
console.log(` ${title}${cat ? ` [${cat}]` : ""} cover:${hasCover ? "yes" : "NO"}`);
console.log(` approve: node agents/scripts/approve.mjs ${d.name}\n`);
}

View file

@ -1,128 +0,0 @@
#!/usr/bin/env node
/**
* @agent-manifest
* name: news-radar
* title: News Radar
* class: content
* trigger: manual / cron-capable (maintains news-queue.json for the writer)
* description: Discover timely niche news and maintain news-queue.json; the daily writer drains it first.
* model: research=claude-opus-4-8
* prompt: prompts/news-radar.system.md
* skills: -
* tools: lib/claude.mjs
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// News radar: discover timely Medellín food news/events and maintain news-queue.json.
// Usage: node agents/scripts/news-radar.mjs
import { existsSync, readFileSync, writeFileSync, readdirSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { runClaude } from "./lib/claude.mjs";
import { loadJson, todayInTz, addDays } from "./lib/util.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const PIPELINE = resolve(HERE, "..");
const cfg = loadJson(join(PIPELINE, "config.json"));
const root = cfg.paths.projectRoot;
const queuePath = join(root, cfg.paths.newsDir, "news-queue.json");
const scanPath = join(root, cfg.paths.newsDir, "news-scan.json");
const loadArr = (p) => (existsSync(p) ? loadJson(p) : []);
const publishedSlugs = () => {
const d = join(root, cfg.paths.blogContentDir);
return existsSync(d)
? readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).map((x) => x.name)
: [];
};
const calendarSlugs = () => {
const p = join(root, cfg.paths.calendar);
return existsSync(p) ? loadJson(p).map((e) => e.slug) : [];
};
/** Scan for fresh news and merge into news-queue.json. Returns the merged queue. */
export async function runNewsRadar() {
if (!cfg.news?.enabled) {
console.log("[news] news.enabled is false — skipping.");
return loadArr(queuePath);
}
const today = todayInTz(cfg.editorial.timezone);
const recencyDays = cfg.news.recencyDays ?? 5;
const maxPerScan = cfg.news.maxPerScan ?? 8;
const existing = loadArr(queuePath);
const known = new Set([...existing.map((i) => i.slug), ...publishedSlugs(), ...calendarSlugs()]);
const prompt = `Find timely Medellín food/restaurant news to publish about now.
- Today: ${today}
- Prefer items from the last ~2-3 weeks. Return at most ${maxPerScan} items (fewer is fine; [] if nothing fresh).
- Write the JSON array to: ${scanPath}
- Do NOT reuse any of these existing slugs: ${[...known].join(", ") || "(none)"}
- Existing published posts: ${join(root, cfg.paths.blogContentDir)}
Follow the output contract in your system prompt exactly.`;
await runClaude({
prompt,
systemPromptFile: join(PIPELINE, "prompts/news-radar.system.md"),
model: cfg.models.research,
allowedTools: "Read Glob Grep WebSearch Write",
addDirs: [root],
cwd: root,
logFile: join(root, cfg.paths.logsDir, `news-${today}.log`),
});
const scanned = loadArr(scanPath);
// Prune: drop stale FRESH items (older than recency window); keep used items for dedup history.
const cutoff = addDays(today, -recencyDays);
const kept = existing.filter((i) => i.status === "used" || (i.discovered || today) >= cutoff);
const keptSlugs = new Set(kept.map((i) => i.slug));
const dedupe = new Set([...keptSlugs, ...publishedSlugs(), ...calendarSlugs()]);
let added = 0;
for (const item of scanned) {
if (!item?.slug || dedupe.has(item.slug)) continue;
kept.push({ ...item, discovered: item.discovered || today, status: "fresh" });
dedupe.add(item.slug);
added++;
}
// Newest + hottest first.
kept.sort((a, b) => (b.freshnessScore ?? 0) - (a.freshnessScore ?? 0) || (b.discovered || "").localeCompare(a.discovered || ""));
writeFileSync(queuePath, JSON.stringify(kept, null, 2));
const fresh = kept.filter((i) => i.status === "fresh").length;
console.log(`[news] scanned ${scanned.length}, added ${added}, queue now ${kept.length} (${fresh} fresh).`);
return kept;
}
/** The freshest unused news item within the recency window, or null. */
export function pickFreshNews(cfg2 = cfg) {
if (!cfg2.news?.enabled || !existsSync(queuePath)) return null;
const today = todayInTz(cfg2.editorial.timezone);
const cutoff = addDays(today, -(cfg2.news.recencyDays ?? 5));
const fresh = loadJson(queuePath)
.filter((i) => i.status === "fresh" && (i.discovered || today) >= cutoff)
.sort((a, b) => (b.freshnessScore ?? 0) - (a.freshnessScore ?? 0) || (b.discovered || "").localeCompare(a.discovered || ""));
return fresh[0] || null;
}
/** Mark a news item used (after it's been drafted). */
export function markNewsUsed(slug) {
if (!existsSync(queuePath)) return;
const q = loadJson(queuePath);
const item = q.find((i) => i.slug === slug);
if (item) {
item.status = "used";
writeFileSync(queuePath, JSON.stringify(q, null, 2));
}
}
// CLI
if (import.meta.url === `file://${process.argv[1]}`) {
await runNewsRadar();
}

View file

@ -1,117 +0,0 @@
#!/usr/bin/env node
/**
* @agent-manifest
* name: publish-tick
* title: Auto-Publisher Tick
* class: plumbing
* trigger: cron:run.sh publish-tick.mjs(15m)
* description: Once per day after the morning floor, publish ONE eligible draft (SEO-gated) with a randomized earlier-today timestamp. No LLM.
* model: -
* prompt: -
* skills: -
* tools: lib/publish.mjs
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// Auto-publisher. Run frequently by cron (e.g. every 15 min). Once per day, after a morning
// floor time, it publishes ONE eligible draft (passing the SEO gate) so posts go live in the
// morning — but stamps each with a RANDOM earlier-today timestamp so published times aren't a
// fixed-minute metronome. News drafts are fast-tracked ahead of evergreen.
// Usage: node agents/scripts/publish-tick.mjs [--now] (--now ignores the floor)
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { loadJson, todayInTz, randomTimestampTodaySoFar } from "./lib/util.mjs";
import { promoteDraft } from "./lib/publish.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const PIPELINE = resolve(HERE, "..");
const cfg = loadJson(join(PIPELINE, "config.json"));
const root = cfg.paths.projectRoot;
const FORCE_NOW = process.argv.includes("--now");
if (!cfg.publish?.auto) {
console.log("[publish-tick] publish.auto is false — nothing to do.");
process.exit(0);
}
const today = todayInTz(cfg.editorial.timezone);
const stateDir = join(root, cfg.paths.stateDir);
mkdirSync(stateDir, { recursive: true });
const statePath = join(stateDir, `publish-${today}.json`);
let state = existsSync(statePath) ? loadJson(statePath) : { published: false, slug: null };
if (state.published) {
console.log(`[publish-tick] already published today (${state.slug}).`);
process.exit(0);
}
// Morning floor: don't publish before this Bogotá time (gives the 06:00 writer time to draft +
// audit + auto-revise so the day's freshest article is the one that goes live).
const notBefore = cfg.publish.notBefore || "07:00";
const floorMs = Date.parse(`${today}T${notBefore}:00-05:00`);
if (!FORCE_NOW && Date.now() < floorMs) {
console.log(`[publish-tick] waiting — floor ${notBefore} Bogotá (${new Date(floorMs).toISOString()}).`);
process.exit(0);
}
// Find an eligible draft: on disk, has cover, and passes the SEO gate.
const minScore = cfg.seo?.minScore ?? 85;
const draftsRoot = join(root, cfg.paths.draftsDir);
const calPath = join(root, cfg.paths.calendar);
const calendar = existsSync(calPath) ? loadJson(calPath) : [];
const entryOf = (slug) => calendar.find((e) => e.slug === slug) || {};
const dateOf = (slug) => entryOf(slug).date || "9999-12-31";
function eligible(slug) {
const d = join(draftsRoot, slug);
if (!existsSync(join(d, "index.mdx")) || !existsSync(join(d, "cover.jpg"))) return false;
const auditPath = join(d, "seo-review.json");
if (!existsSync(auditPath)) return false;
try {
const a = loadJson(auditPath);
return a.verdict !== "fail" && (a.overall ?? 0) >= minScore;
} catch {
return false;
}
}
const candidates = existsSync(draftsRoot)
? readdirSync(draftsRoot, { withFileTypes: true })
.filter((x) => x.isDirectory())
.map((x) => x.name)
.filter(eligible)
// News drafts first (hottest, then by date); evergreen after, oldest first.
.sort((a, b) => {
const ea = entryOf(a);
const eb = entryOf(b);
const na = ea.news ? 1 : 0;
const nb = eb.news ? 1 : 0;
if (na !== nb) return nb - na;
if (na) return (eb.freshnessScore ?? 0) - (ea.freshnessScore ?? 0) || dateOf(a).localeCompare(dateOf(b));
return dateOf(a).localeCompare(dateOf(b));
})
: [];
if (!candidates.length) {
console.log("[publish-tick] no SEO-passing draft ready to publish (leaving queue as-is).");
process.exit(0);
}
const slug = candidates[0];
const stamp = randomTimestampTodaySoFar(cfg.editorial.timezone); // morning go-live, random timestamp
console.log(`[publish-tick] auto-publishing "${slug}" with timestamp ${stamp}`);
try {
const { url, taxonomyAdded } = await promoteDraft(cfg, slug, { dateOverride: stamp });
if (taxonomyAdded.length) console.log(`[publish-tick] registered: ${taxonomyAdded.join(", ")}`);
state = { published: true, slug, publishedAt: stamp };
writeFileSync(statePath, JSON.stringify(state, null, 2));
console.log(`[publish-tick] LIVE → ${url}`);
} catch (e) {
console.error(`[publish-tick] publish failed: ${e.message}`);
process.exit(1);
}

View file

@ -1,110 +0,0 @@
#!/usr/bin/env node
/**
* @agent-manifest
* name: research
* title: Editorial Calendar Researcher
* class: content
* trigger: manual (auto-invoked by writer when the evergreen backlog runs low)
* description: Top up the evergreen backlog in calendar.json (additive, deduped); timely news is the news radar's job.
* model: research=claude-opus-4-8
* prompt: prompts/research.system.md
* skills: -
* tools: lib/claude.mjs, lib/calendar.mjs
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// Editorial research: top up the EVERGREEN backlog in calendar.json (additive, deduped).
// Timely news is handled separately by the news radar. Usage:
// node agents/scripts/research.mjs [count]
import { readdirSync, existsSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { runClaude } from "./lib/claude.mjs";
import { loadJson, todayInTz, addDays } from "./lib/util.mjs";
import { readCalendar, mergeEntries } from "./lib/calendar.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const PIPELINE = resolve(HERE, "..");
const cfg = loadJson(join(PIPELINE, "config.json"));
const root = cfg.paths.projectRoot;
const publishedSlugs = () => {
const d = join(root, cfg.paths.blogContentDir);
return existsSync(d)
? readdirSync(d, { withFileTypes: true }).filter((x) => x.isDirectory()).map((x) => x.name)
: [];
};
/** Scan for evergreen topics and additively merge them into calendar.json. Returns count added. */
export async function runResearch({ count } = {}) {
const n = count || cfg.editorial.calendarDays;
const today = todayInTz(cfg.editorial.timezone);
const calendarPath = join(root, cfg.paths.calendar);
const scanPath = join(root, "agents", "research-scan.json");
// Per-type target counts from the configured mix.
const types = Object.keys(cfg.editorial.contentMix);
const counts = {};
let assigned = 0;
for (const t of types) {
counts[t] = Math.round(cfg.editorial.contentMix[t] * n);
assigned += counts[t];
}
const biggest = types.reduce((a, b) => (counts[a] >= counts[b] ? a : b));
counts[biggest] += n - assigned;
const exclude = [...new Set([...readCalendar(calendarPath).map((e) => e.slug), ...publishedSlugs()])];
const prompt = `Propose ${n} evergreen Comiida topics.
- Write a JSON array of ${n} topic proposals to: ${scanPath}
- Do NOT assign dates (the pipeline schedules them).
- Allowed content types: ${types.join(", ")}
- Rough target counts per type: ${JSON.stringify(counts)}
- DO NOT reuse any of these slugs: ${exclude.join(", ") || "(none yet)"}
- Read existing posts if useful: ${join(root, cfg.paths.blogContentDir)}
Follow the output contract in your system prompt exactly.`;
console.log(`[research] proposing ${n} evergreen topics, mix ${JSON.stringify(counts)}`);
await runClaude({
prompt,
systemPromptFile: join(PIPELINE, "prompts/research.system.md"),
model: cfg.models.research,
allowedTools: "Read Glob Grep WebSearch Write",
addDirs: [root],
cwd: root,
logFile: join(root, cfg.paths.logsDir, `research-${today}.log`),
});
const proposals = existsSync(scanPath) ? loadJson(scanPath) : [];
if (!proposals.length) {
console.log("[research] no proposals produced.");
return 0;
}
// Schedule new topics one per day, starting the day after the latest existing date.
const dates = readCalendar(calendarPath).map((e) => (e.date || "").slice(0, 10)).filter(Boolean);
const maxDate = dates.sort().pop();
let next = maxDate && maxDate >= today ? addDays(maxDate, 1) : today;
const entries = proposals.map((p) => {
const entry = { ...p, date: next, status: "planned" };
next = addDays(next, 1);
return entry;
});
const added = await mergeEntries(calendarPath, entries);
const planned = readCalendar(calendarPath).filter((e) => e.status === "planned").length;
console.log(`[research] added ${added} evergreen topics. Planned backlog now: ${planned}.`);
return added;
}
// CLI
if (import.meta.url === `file://${process.argv[1]}`) {
const count = parseInt(process.argv[2], 10) || undefined;
await runResearch({ count });
}

View file

@ -1,107 +0,0 @@
#!/usr/bin/env node
/**
* @agent-manifest
* name: reviser
* title: Draft Reviser
* class: content
* trigger: manual <slug> (auto-invoked when the SEO gate fails and autoRevise is on)
* description: Feed the SEO audit's fixes back to the writer and re-audit until the draft passes or attempts run out.
* model: writer=claude-sonnet-4-6
* prompt: prompts/reviser.system.md
* skills: -
* tools: lib/claude.mjs
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// Auto-revise loop: feed the SEO audit's fixes back to the writer, re-audit, repeat until the
// draft passes the gate or maxReviseAttempts is reached.
// Usage: node agents/scripts/revise.mjs <slug>
import { existsSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { runClaude } from "./lib/claude.mjs";
import { loadJson } from "./lib/util.mjs";
import { runSeoReview } from "./seo-review.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const PIPELINE = resolve(HERE, "..");
const cfg = loadJson(join(PIPELINE, "config.json"));
const root = cfg.paths.projectRoot;
const failing = (audit, minScore) =>
!audit || audit.verdict === "fail" || (audit.overall ?? 0) < minScore;
/**
* Revise a draft until it passes the SEO gate (or attempts run out).
* @returns {Promise<{audit:object|null, attempts:number}>}
*/
export async function runRevise(slug, { maxAttempts } = {}) {
const max = maxAttempts ?? cfg.seo?.maxReviseAttempts ?? 2;
const minScore = cfg.seo?.minScore ?? 85;
const draftDir = join(root, cfg.paths.draftsDir, slug);
const mdxPath = join(draftDir, "index.mdx");
const auditPath = join(draftDir, "seo-review.json");
if (!existsSync(mdxPath)) {
console.error(`[revise] no draft at ${mdxPath}`);
return { audit: null, attempts: 0 };
}
const calPathForEntry = join(root, cfg.paths.calendar);
const calEntry = existsSync(calPathForEntry)
? loadJson(calPathForEntry).find((e) => e.slug === slug)
: null;
const firsthandNote = calEntry?.authorFirsthand
? `\n\nIMPORTANT — authorFirsthand piece: a real, named author (${cfg.author?.name || "the site author"}) wrote this personally. Do NOT strip or neutralize the first-person voice and subjective opinions — that is authentic Experience and must be kept. Only add citations for objective facts (addresses, prices, dates, hours) or frame them honestly.`
: "";
let audit = existsSync(auditPath) ? loadJson(auditPath) : await runSeoReview(slug, { published: false });
let attempts = 0;
while (failing(audit, minScore) && attempts < max) {
attempts++;
const keyword = audit?.primaryKeyword || slug.replace(/-/g, " ");
console.log(`[revise] attempt ${attempts}/${max} on ${slug} (current: ${audit?.verdict} ${audit?.overall})`);
const prompt = `Revise this draft to pass the SEO/EEAT gate (target ≥ ${minScore}).
- Draft: ${mdxPath}
- Citations file: ${join(draftDir, "sources.json")}
- Audit to address (work through blocking + topFixes): ${auditPath}
- Primary keyword: ${keyword}
- Existing published posts (for internal links): ${join(root, cfg.paths.blogContentDir)}${firsthandNote}
Follow the reviser instructions in your system prompt exactly. Edit the files in place.`;
await runClaude({
prompt,
systemPromptFile: join(PIPELINE, "prompts/reviser.system.md"),
model: cfg.models.writer,
allowedTools: "Read Write Edit Glob Grep WebSearch",
addDirs: [root],
cwd: root,
logFile: join(root, cfg.paths.logsDir, `revise-${slug}.log`),
});
audit = await runSeoReview(slug, { published: false });
}
const ok = !failing(audit, minScore);
console.log(
`[revise] ${slug}: ${ok ? "PASS" : "still failing"} after ${attempts} attempt(s) — ` +
`${audit?.verdict} ${audit?.overall}`
);
return { audit, attempts };
}
// CLI
if (import.meta.url === `file://${process.argv[1]}`) {
const slug = process.argv[2];
if (!slug) {
console.error("Usage: revise.mjs <slug>");
process.exit(1);
}
const { audit } = await runRevise(slug);
process.exit(audit && audit.verdict === "pass" ? 0 : 2);
}

View file

@ -1,128 +0,0 @@
#!/usr/bin/env node
/**
* @agent-manifest
* name: seo-review
* title: SEO Reviewer
* class: content
* trigger: manual <slug> (auto-invoked by writer/approve gates)
* description: Audit one article for EEAT / spam-policy / on-page SEO / AEO / readability seo-review.json.
* model: reviewer=claude-opus-4-8
* prompt: prompts/seo-review.system.md
* skills: -
* tools: lib/claude.mjs
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// SEO Specialist agent: audit one article for EEAT / spam-policy / on-page SEO / AEO / readability.
// Writes <postDir>/seo-review.json. Usage:
// node scripts/seo-review.mjs <slug> [--published]
// (default: review the draft in agents/drafts/<slug>; --published reviews the
// live post in app/src/content/blog/<slug>)
import { existsSync, readFileSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { runClaude } from "./lib/claude.mjs";
import { loadJson, readFrontmatter, fmString } from "./lib/util.mjs";
import { readSlugs } from "./lib/blogData.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const PIPELINE = resolve(HERE, "..");
const cfg = loadJson(join(PIPELINE, "config.json"));
const root = cfg.paths.projectRoot;
function postDirFor(slug, published) {
const draftDir = join(root, cfg.paths.draftsDir, slug);
const liveDir = join(root, cfg.paths.blogContentDir, slug);
if (published) return liveDir;
if (existsSync(join(draftDir, "index.mdx"))) return draftDir;
if (existsSync(join(liveDir, "index.mdx"))) return liveDir;
return draftDir; // will fail the existence check below
}
function primaryKeywordFor(slug, mdxPath) {
const calPath = join(root, cfg.paths.calendar);
if (existsSync(calPath)) {
const entry = loadJson(calPath).find((e) => e.slug === slug);
if (entry?.primaryKeyword) return entry.primaryKeyword;
}
if (existsSync(mdxPath)) {
const t = fmString(readFrontmatter(readFileSync(mdxPath, "utf8")), "title");
if (t) return t;
}
return slug.replace(/-/g, " ");
}
/** Run the SEO audit for one post. Returns the parsed audit object, or null on failure. */
export async function runSeoReview(slug, { published = false } = {}) {
const postDir = postDirFor(slug, published);
const mdxPath = join(postDir, "index.mdx");
if (!existsSync(mdxPath)) {
console.error(`[seo] no article at ${mdxPath}`);
return null;
}
const keyword = primaryKeywordFor(slug, mdxPath);
const calPathForEntry = join(root, cfg.paths.calendar);
const calEntry = existsSync(calPathForEntry)
? loadJson(calPathForEntry).find((e) => e.slug === slug)
: null;
const firsthandNote = calEntry?.authorFirsthand
? `\n\nIMPORTANT — authorFirsthand piece: a real, named author (${cfg.author?.name || "the site author"}) wrote this personally from genuine first-hand experience. First-person voice and subjective opinions are LEGITIMATE Experience (positive E-E-A-T), NOT fabrication. Do not flag the personal voice as an EEAT/spam violation; only require citations for objective, verifiable facts (addresses, prices, dates, hours).`
: "";
const blogDir = join(root, cfg.paths.blogContentDir);
const categories = [...readSlugs(join(root, cfg.paths.blogDataFile)).categories];
const auditPath = join(postDir, "seo-review.json");
const logFile = join(root, cfg.paths.logsDir, `seo-${slug}.log`);
const prompt = `Audit this Comiida article.
- Article: ${mdxPath}
- Citations file (if present): ${join(postDir, "sources.json")}
- Primary keyword to target: ${keyword}
- Existing published posts (for internal-link validation): ${blogDir}
- Allowed category slugs: ${categories.join(", ")}
- Write your audit JSON to: ${auditPath}${firsthandNote}
Follow the rubric and output contract in your system prompt exactly.`;
await runClaude({
prompt,
systemPromptFile: join(PIPELINE, "prompts/seo-review.system.md"),
model: cfg.models.reviewer,
allowedTools: "Read Glob Grep WebSearch Write",
addDirs: [root],
cwd: root,
logFile,
});
if (!existsSync(auditPath)) {
console.error(`[seo] audit not written: ${auditPath}`);
return null;
}
try {
return loadJson(auditPath);
} catch (e) {
console.error(`[seo] could not parse audit: ${e.message}`);
return null;
}
}
// CLI
if (import.meta.url === `file://${process.argv[1]}`) {
const slug = process.argv[2];
const published = process.argv.includes("--published");
if (!slug) {
console.error("Usage: seo-review.mjs <slug> [--published]");
process.exit(1);
}
const audit = await runSeoReview(slug, { published });
if (!audit) process.exit(1);
const nb = (audit.blocking || []).length;
const nf = (audit.topFixes || []).length;
console.log(`[seo] ${slug}: ${audit.verdict} · ${audit.overall} · ${nb} blocking · ${nf} fixes`);
if (audit.summary) console.log(`[seo] ${audit.summary}`);
if (nb) console.log("[seo] blocking:\n - " + audit.blocking.join("\n - "));
process.exit(audit.verdict === "pass" ? 0 : audit.verdict === "revise" ? 2 : 3);
}

View file

@ -1,215 +0,0 @@
#!/usr/bin/env node
/**
* @agent-manifest
* name: writer
* title: Daily Blog Writer
* class: content
* trigger: cron:run.sh write-daily.mjs(daily)
* description: Draft one article (MDX + cover + sources) today's news first, else next evergreen calendar topic.
* model: writer=claude-sonnet-4-6
* prompt: prompts/writer.system.md, prompts/image.md
* skills: mcp__claude_ai_Higgsfield
* tools: lib/claude.mjs, lib/calendar.mjs
* reads: -
* writes: -
* created: 2026-07-04
* @end
*/
// Daily writer: draft one article (MDX + cover.jpg + sources.json) into drafts/<slug>/.
// Selection order: today's news (news radar) first → else the next evergreen calendar topic.
// Refills the evergreen backlog on demand when it runs low. All calendar writes are atomic.
// Usage: node agents/scripts/write-daily.mjs [slug]
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { dirname, resolve, join } from "node:path";
import { fileURLToPath } from "node:url";
import { runClaude } from "./lib/claude.mjs";
import { loadJson, todayInTz } from "./lib/util.mjs";
import { readCalendar, updateEntry, addEntry } from "./lib/calendar.mjs";
import { runSeoReview } from "./seo-review.mjs";
import { runRevise } from "./revise.mjs";
import { runNewsRadar, pickFreshNews, markNewsUsed } from "./news-radar.mjs";
import { runResearch } from "./research.mjs";
const HERE = dirname(fileURLToPath(import.meta.url));
const PIPELINE = resolve(HERE, "..");
const cfg = loadJson(join(PIPELINE, "config.json"));
const root = cfg.paths.projectRoot;
if (cfg.author.slug === "REPLACE_ME") {
console.error("[write-daily] config.author is not set. Add the real author first.");
process.exit(1);
}
const calendarPath = join(root, cfg.paths.calendar);
const today = todayInTz(cfg.editorial.timezone);
const stateDir = join(root, cfg.paths.stateDir);
const argSlug = process.argv[2];
let entry;
if (argSlug) {
entry = readCalendar(calendarPath).find((e) => e.slug === argSlug);
if (!entry) {
console.error(`[write-daily] slug not found in calendar: ${argSlug}`);
process.exit(1);
}
} else {
// 0) User suggestions (from the admin dashboard) take top priority.
const suggestion = readCalendar(calendarPath)
.filter((e) => e.suggested && e.status === "planned")
.sort((a, b) => (a.date || "").localeCompare(b.date || ""))[0];
if (suggestion) {
entry = suggestion;
console.log(`[write-daily] suggestion-first: "${entry.workingTitle}"`);
} else {
mkdirSync(stateDir, { recursive: true });
// 1) Ensure today's news scan ran (guarded once/day so the cadence doesn't matter).
if (cfg.news?.enabled) {
const newsStatePath = join(stateDir, `news-${today}.json`);
if (!existsSync(newsStatePath)) {
try {
await runNewsRadar();
} catch (e) {
console.log(`[write-daily] news radar error (continuing): ${e.message}`);
}
writeFileSync(newsStatePath, JSON.stringify({ scanned: today }, null, 2));
}
}
// 2) Demand-driven evergreen refill when the backlog is low (guarded once/day).
const threshold = cfg.editorial.refillThreshold ?? 7;
const plannedCount = readCalendar(calendarPath).filter((e) => e.status === "planned").length;
const refillStatePath = join(stateDir, `research-${today}.json`);
if (plannedCount < threshold && !existsSync(refillStatePath)) {
console.log(`[write-daily] evergreen backlog low (${plannedCount} < ${threshold}) — refilling…`);
try {
const added = await runResearch({ count: cfg.editorial.refillCount ?? 14 });
console.log(`[write-daily] refill added ${added} topics.`);
} catch (e) {
console.log(`[write-daily] refill error (continuing): ${e.message}`);
}
writeFileSync(refillStatePath, JSON.stringify({ refilled: today }, null, 2));
}
// 3) News-first selection; inject the chosen news item as a calendar entry. Else evergreen.
const news = pickFreshNews(cfg);
if (news) {
entry = {
date: today,
slug: news.slug,
workingTitle: news.workingTitle,
type: news.type || "news-roundup",
primaryKeyword: news.primaryKeyword,
secondaryKeywords: news.secondaryKeywords || [],
searchIntent: news.searchIntent || "informational",
audienceAngle: news.newsHook || "",
sourceHints: news.sourceLinks || [],
eeatAngle: "Timely, sourced news — cite every claim.",
news: true,
freshnessScore: news.freshnessScore ?? 3,
status: "planned",
};
await addEntry(calendarPath, entry);
markNewsUsed(news.slug);
console.log(`[write-daily] news-first: "${entry.workingTitle}" (freshness ${entry.freshnessScore})`);
} else {
const cal = readCalendar(calendarPath);
const planned = cal.filter((e) => e.status === "planned");
const due = planned.filter((e) => e.date <= today).sort((a, b) => a.date.localeCompare(b.date));
entry = due[0] || planned.sort((a, b) => a.date.localeCompare(b.date))[0];
}
}
}
if (!entry) {
console.log("[write-daily] Nothing left to write — no fresh news and no planned topics.");
process.exit(0);
}
const draftDir = join(root, cfg.paths.draftsDir, entry.slug);
mkdirSync(draftDir, { recursive: true });
const [wMin, wMax] = cfg.editorial.wordCounts[entry.type] || [800, 1200];
const blogDir = join(root, cfg.paths.blogContentDir);
const logFile = join(root, cfg.paths.logsDir, `write-${entry.slug}.log`);
const prompt = `Write today's Comiida article from this calendar entry:
${JSON.stringify({ ...entry, draft: undefined, instructions: undefined }, null, 2)}
- Today's date: ${today}
- Author slug to byline (exact): ${cfg.author.slug}
- Word count range for type "${entry.type}": ${wMin}${wMax} words
- Draft output directory (write index.mdx, cover.jpg, sources.json here): ${draftDir}
- Existing published posts to read for internal links: ${blogDir}
- Image: use the Higgsfield MCP (preferred model "${cfg.image.preferredModel}", aspect ratio
~${cfg.image.aspectRatio}). Read agents/prompts/image.md and follow that flow
(recommend model generate_image job_status sync curl the result URL).
Save the final image to: ${join(draftDir, "cover.jpg")}.
${
entry.authorFirsthand
? `\nAUTHOR FIRST-HAND PIECE: this is ${cfg.author.name}'s own draft, written from genuine first-hand experience. Follow the "Author first-hand pieces" EXCEPTION in your system prompt — keep the FIRST-PERSON voice and the author's honest opinions (do not neutralize them), and only fact-check/cite objective specifics (addresses, opening dates, prices, hours). SCOPE: the article is about the venues and points the author actually names — cover those; do NOT pad the piece with generic restaurants the author didn't mention just to hit a word count. Aim for the LOWER end of the word-count range; a tight, genuine ~8001000 words beats bloated filler.\n`
: ""
}${
entry.instructions
? `\nEDITOR INSTRUCTIONS (from the person who suggested this — follow them carefully):\n${entry.instructions}\n`
: ""
}${
entry.draft
? `\nEDITOR-PROVIDED DRAFT — use this as the basis for the article. Keep its intent and key points, but fact-check every claim, add real citations, improve structure/SEO, and expand it to meet the contract:\n"""\n${entry.draft}\n"""\n`
: ""
}
Follow the EEAT/SEO contract in your system prompt exactly.`;
await updateEntry(calendarPath, entry.slug, { status: "drafting" });
console.log(`[write-daily] drafting "${entry.workingTitle}" (${entry.type}) → ${draftDir}`);
const res = await runClaude({
prompt,
systemPromptFile: join(PIPELINE, "prompts/writer.system.md"),
model: cfg.models.writer,
// Higgsfield is configured globally via claude.ai (reachable headless) — no local --mcp-config.
allowedTools: "Read Write Edit Glob Grep WebSearch Bash mcp__claude_ai_Higgsfield",
addDirs: [root],
cwd: root,
logFile,
});
console.log(`[write-daily] agent: ${res.result || "(no text)"}`);
const hasMdx = existsSync(join(draftDir, "index.mdx"));
const hasCover = existsSync(join(draftDir, "cover.jpg"));
const finalStatus = hasMdx ? (hasCover ? "drafted" : "drafted-no-image") : "draft-failed";
await updateEntry(calendarPath, entry.slug, { status: finalStatus });
console.log(
`[write-daily] status=${finalStatus} mdx=${hasMdx} cover=${hasCover}\n` +
`[write-daily] review: ${draftDir} | log: ${logFile}`
);
if (!hasMdx) process.exit(1);
// SEO Specialist pass + auto-revise loop, so the approve/publish gate has a verdict.
try {
let audit = await runSeoReview(entry.slug, { published: false });
const minScore = cfg.seo?.minScore ?? 85;
const fails = (a) => !a || a.verdict === "fail" || (a.overall ?? 0) < minScore;
if (audit && cfg.seo?.autoRevise && fails(audit)) {
console.log(`[write-daily] SEO ${audit.verdict} ${audit.overall} < ${minScore} — auto-revising…`);
const { audit: revised, attempts } = await runRevise(entry.slug, {});
if (revised) audit = revised;
console.log(`[write-daily] auto-revise done after ${attempts} attempt(s): ${audit?.verdict} ${audit?.overall}`);
}
if (audit) {
await updateEntry(calendarPath, entry.slug, { seo: { verdict: audit.verdict, overall: audit.overall } });
console.log(
`[write-daily] SEO: ${audit.verdict} · ${audit.overall} · ${(audit.blocking || []).length} blocking`
);
} else {
console.log("[write-daily] SEO: audit could not be generated (continuing).");
}
} catch (e) {
console.log(`[write-daily] SEO review error (continuing): ${e.message}`);
}

View file

View file

@ -113,8 +113,8 @@ should be deleted/rotated by the operator.
Reuses the existing tables — no duplication. Reuses the existing tables — no duplication.
- **`ApiController`** (base, privileged) — verifies a bearer token: either the - **`ApiController`** (base, privileged) — verifies a bearer token: either the
`ADMIN_TOKEN` (config constant; same token pattern as `/devconsole` & `/admin`) `ADMIN_TOKEN` (config constant) for first-party/admin calls, **or** an
for first-party/admin calls, **or** an `api_auth.apikey` for programmatic `api_auth.apikey` for programmatic
consumers. On an API key: check `active`, enforce `api_plans.limit_minute` / consumers. On an API key: check `active`, enforce `api_plans.limit_minute` /
`limit_monthly` against `api_usage`, and log to `api_requests`. Failures → `limit_monthly` against `api_usage`, and log to `api_requests`. Failures →
`401` (missing/invalid) or `429` (over limit). `401` (missing/invalid) or `429` (over limit).

View file

@ -1,38 +0,0 @@
<?php
/**
* Resource registry bridge for the content agents. Reads curated sources (mde_resources)
* per agent_type so the Node pipeline never needs DB creds.
*
* php api/cli/resources.php list <agent_type> -> JSON array of active resources
* php api/cli/resources.php touch <id> [<id>...] -> stamp last_used_at
*/
require __DIR__ . '/../vendor/autoload.php';
require __DIR__ . '/../config.php';
$cmd = $argv[1] ?? '';
if ($cmd === 'list') {
$type = $argv[2] ?? '';
if ($type === '') { fwrite(STDERR, "usage: resources.php list <agent_type>\n"); exit(1); }
$rows = \Db::select(
"SELECT resource_id, name, url, kind, notes, priority
FROM mde_resources
WHERE agent_type = ? AND active = 1
ORDER BY priority DESC, resource_id ASC",
[$type]
);
echo json_encode($rows, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
exit;
}
if ($cmd === 'touch') {
$ids = array_slice($argv, 2);
foreach ($ids as $id) {
\Db::execute("UPDATE mde_resources SET last_used_at = NOW() WHERE resource_id = ?", [(int) $id]);
}
echo json_encode(['ok' => true, 'touched' => count($ids)]);
exit;
}
fwrite(STDERR, "usage: resources.php list <agent_type> | touch <id...>\n");
exit(1);

View file

@ -1,711 +0,0 @@
---
name: ui-ux-pro-max
description: "UI/UX design intelligence for web and mobile. Includes 50+ styles, 161 color palettes, 57 font pairings, 161 product types, 99 UX guidelines, and 25 chart types across 10 stacks (React, Next.js, Vue, Svelte, SwiftUI, React Native, Flutter, Tailwind, shadcn/ui, and HTML/CSS). Actions: plan, build, create, design, implement, review, fix, improve, optimize, enhance, refactor, and check UI/UX code. Projects: website, landing page, dashboard, admin panel, e-commerce, SaaS, portfolio, blog, and mobile app. Elements: button, modal, navbar, sidebar, card, table, form, and chart. Styles: glassmorphism, claymorphism, minimalism, brutalism, neumorphism, bento grid, dark mode, responsive, skeuomorphism, and flat design. Topics: color systems, accessibility, animation, layout, typography, font pairing, spacing, interaction states, shadow, and gradient. Integrations: shadcn/ui MCP for component search and examples."
---
> **astroagent environment note:** You run headless in a sandbox with **no shell/scripts**. Ignore any instruction here to run search scripts or `--domain` / `--design-system` / CLI queries — apply the written guidance directly. This project is an **Astro + Tailwind** website: prefer the Web/CSS/Tailwind rules and treat native iOS/Android-only items (haptics, VoiceOver, safe-area) as optional. Always match the project's existing design system first (read AGENTS.md / DESIGN.md and nearby components).
# UI/UX Pro Max - Design Intelligence
Comprehensive design guide for web and mobile applications. Contains 50+ styles, 161 color palettes, 57 font pairings, 161 product types with reasoning rules, 99 UX guidelines, and 25 chart types across 10 technology stacks. Searchable database with priority-based recommendations.
## When to Apply
This Skill should be used when the task involves **UI structure, visual design decisions, interaction patterns, or user experience quality control**.
### Must Use
This Skill must be invoked in the following situations:
- Designing new pages (Landing Page, Dashboard, Admin, SaaS, Mobile App)
- Creating or refactoring UI components (buttons, modals, forms, tables, charts, etc.)
- Choosing color schemes, typography systems, spacing standards, or layout systems
- Reviewing UI code for user experience, accessibility, or visual consistency
- Implementing navigation structures, animations, or responsive behavior
- Making product-level design decisions (style, information hierarchy, brand expression)
- Improving perceived quality, clarity, or usability of interfaces
### Recommended
This Skill is recommended in the following situations:
- UI looks "not professional enough" but the reason is unclear
- Receiving feedback on usability or experience
- Pre-launch UI quality optimization
- Aligning cross-platform design (Web / iOS / Android)
- Building design systems or reusable component libraries
### Skip
This Skill is not needed in the following situations:
- Pure backend logic development
- Only involving API or database design
- Performance optimization unrelated to the interface
- Infrastructure or DevOps work
- Non-visual scripts or automation tasks
**Decision criteria**: If the task will change how a feature **looks, feels, moves, or is interacted with**, this Skill should be used.
## Rule Categories by Priority
*For human/AI reference: follow priority 1→10 to decide which rule category to focus on first; use `--domain <Domain>` to query details when needed. Scripts do not read this table.*
| Priority | Category | Impact | Domain | Key Checks (Must Have) | Anti-Patterns (Avoid) |
|----------|----------|--------|--------|------------------------|------------------------|
| 1 | Accessibility | CRITICAL | `ux` | Contrast 4.5:1, Alt text, Keyboard nav, Aria-labels | Removing focus rings, Icon-only buttons without labels |
| 2 | Touch & Interaction | CRITICAL | `ux` | Min size 44×44px, 8px+ spacing, Loading feedback | Reliance on hover only, Instant state changes (0ms) |
| 3 | Performance | HIGH | `ux` | WebP/AVIF, Lazy loading, Reserve space (CLS &lt; 0.1) | Layout thrashing, Cumulative Layout Shift |
| 4 | Style Selection | HIGH | `style`, `product` | Match product type, Consistency, SVG icons (no emoji) | Mixing flat & skeuomorphic randomly, Emoji as icons |
| 5 | Layout & Responsive | HIGH | `ux` | Mobile-first breakpoints, Viewport meta, No horizontal scroll | Horizontal scroll, Fixed px container widths, Disable zoom |
| 6 | Typography & Color | MEDIUM | `typography`, `color` | Base 16px, Line-height 1.5, Semantic color tokens | Text &lt; 12px body, Gray-on-gray, Raw hex in components |
| 7 | Animation | MEDIUM | `ux` | Duration 150300ms, Motion conveys meaning, Spatial continuity | Decorative-only animation, Animating width/height, No reduced-motion |
| 8 | Forms & Feedback | MEDIUM | `ux` | Visible labels, Error near field, Helper text, Progressive disclosure | Placeholder-only label, Errors only at top, Overwhelm upfront |
| 9 | Navigation Patterns | HIGH | `ux` | Predictable back, Bottom nav ≤5, Deep linking | Overloaded nav, Broken back behavior, No deep links |
| 10 | Charts & Data | LOW | `chart` | Legends, Tooltips, Accessible colors | Relying on color alone to convey meaning |
## Quick Reference
### 1. Accessibility (CRITICAL)
- `color-contrast` - Minimum 4.5:1 ratio for normal text (large text 3:1); Material Design
- `focus-states` - Visible focus rings on interactive elements (24px; Apple HIG, MD)
- `alt-text` - Descriptive alt text for meaningful images
- `aria-labels` - aria-label for icon-only buttons; accessibilityLabel in native (Apple HIG)
- `keyboard-nav` - Tab order matches visual order; full keyboard support (Apple HIG)
- `form-labels` - Use label with for attribute
- `skip-links` - Skip to main content for keyboard users
- `heading-hierarchy` - Sequential h1→h6, no level skip
- `color-not-only` - Don't convey info by color alone (add icon/text)
- `dynamic-type` - Support system text scaling; avoid truncation as text grows (Apple Dynamic Type, MD)
- `reduced-motion` - Respect prefers-reduced-motion; reduce/disable animations when requested (Apple Reduced Motion API, MD)
- `voiceover-sr` - Meaningful accessibilityLabel/accessibilityHint; logical reading order for VoiceOver/screen readers (Apple HIG, MD)
- `escape-routes` - Provide cancel/back in modals and multi-step flows (Apple HIG)
- `keyboard-shortcuts` - Preserve system and a11y shortcuts; offer keyboard alternatives for drag-and-drop (Apple HIG)
### 2. Touch & Interaction (CRITICAL)
- `touch-target-size` - Min 44×44pt (Apple) / 48×48dp (Material); extend hit area beyond visual bounds if needed
- `touch-spacing` - Minimum 8px/8dp gap between touch targets (Apple HIG, MD)
- `hover-vs-tap` - Use click/tap for primary interactions; don't rely on hover alone
- `loading-buttons` - Disable button during async operations; show spinner or progress
- `error-feedback` - Clear error messages near problem
- `cursor-pointer` - Add cursor-pointer to clickable elements (Web)
- `gesture-conflicts` - Avoid horizontal swipe on main content; prefer vertical scroll
- `tap-delay` - Use touch-action: manipulation to reduce 300ms delay (Web)
- `standard-gestures` - Use platform standard gestures consistently; don't redefine (e.g. swipe-back, pinch-zoom) (Apple HIG)
- `system-gestures` - Don't block system gestures (Control Center, back swipe, etc.) (Apple HIG)
- `press-feedback` - Visual feedback on press (ripple/highlight; MD state layers)
- `haptic-feedback` - Use haptic for confirmations and important actions; avoid overuse (Apple HIG)
- `gesture-alternative` - Don't rely on gesture-only interactions; always provide visible controls for critical actions
- `safe-area-awareness` - Keep primary touch targets away from notch, Dynamic Island, gesture bar and screen edges
- `no-precision-required` - Avoid requiring pixel-perfect taps on small icons or thin edges
- `swipe-clarity` - Swipe actions must show clear affordance or hint (chevron, label, tutorial)
- `drag-threshold` - Use a movement threshold before starting drag to avoid accidental drags
### 3. Performance (HIGH)
- `image-optimization` - Use WebP/AVIF, responsive images (srcset/sizes), lazy load non-critical assets
- `image-dimension` - Declare width/height or use aspect-ratio to prevent layout shift (Core Web Vitals: CLS)
- `font-loading` - Use font-display: swap/optional to avoid invisible text (FOIT); reserve space to reduce layout shift (MD)
- `font-preload` - Preload only critical fonts; avoid overusing preload on every variant
- `critical-css` - Prioritize above-the-fold CSS (inline critical CSS or early-loaded stylesheet)
- `lazy-loading` - Lazy load non-hero components via dynamic import / route-level splitting
- `bundle-splitting` - Split code by route/feature (React Suspense / Next.js dynamic) to reduce initial load and TTI
- `third-party-scripts` - Load third-party scripts async/defer; audit and remove unnecessary ones (MD)
- `reduce-reflows` - Avoid frequent layout reads/writes; batch DOM reads then writes
- `content-jumping` - Reserve space for async content to avoid layout jumps (Core Web Vitals: CLS)
- `lazy-load-below-fold` - Use loading="lazy" for below-the-fold images and heavy media
- `virtualize-lists` - Virtualize lists with 50+ items to improve memory efficiency and scroll performance
- `main-thread-budget` - Keep per-frame work under ~16ms for 60fps; move heavy tasks off main thread (HIG, MD)
- `progressive-loading` - Use skeleton screens / shimmer instead of long blocking spinners for >1s operations (Apple HIG)
- `input-latency` - Keep input latency under ~100ms for taps/scrolls (Material responsiveness standard)
- `tap-feedback-speed` - Provide visual feedback within 100ms of tap (Apple HIG)
- `debounce-throttle` - Use debounce/throttle for high-frequency events (scroll, resize, input)
- `offline-support` - Provide offline state messaging and basic fallback (PWA / mobile)
- `network-fallback` - Offer degraded modes for slow networks (lower-res images, fewer animations)
### 4. Style Selection (HIGH)
- `style-match` - Match style to product type (use `--design-system` for recommendations)
- `consistency` - Use same style across all pages
- `no-emoji-icons` - Use SVG icons (Heroicons, Lucide), not emojis
- `color-palette-from-product` - Choose palette from product/industry (search `--domain color`)
- `effects-match-style` - Shadows, blur, radius aligned with chosen style (glass / flat / clay etc.)
- `platform-adaptive` - Respect platform idioms (iOS HIG vs Material): navigation, controls, typography, motion
- `state-clarity` - Make hover/pressed/disabled states visually distinct while staying on-style (Material state layers)
- `elevation-consistent` - Use a consistent elevation/shadow scale for cards, sheets, modals; avoid random shadow values
- `dark-mode-pairing` - Design light/dark variants together to keep brand, contrast, and style consistent
- `icon-style-consistent` - Use one icon set/visual language (stroke width, corner radius) across the product
- `system-controls` - Prefer native/system controls over fully custom ones; only customize when branding requires it (Apple HIG)
- `blur-purpose` - Use blur to indicate background dismissal (modals, sheets), not as decoration (Apple HIG)
- `primary-action` - Each screen should have only one primary CTA; secondary actions visually subordinate (Apple HIG)
### 5. Layout & Responsive (HIGH)
- `viewport-meta` - width=device-width initial-scale=1 (never disable zoom)
- `mobile-first` - Design mobile-first, then scale up to tablet and desktop
- `breakpoint-consistency` - Use systematic breakpoints (e.g. 375 / 768 / 1024 / 1440)
- `readable-font-size` - Minimum 16px body text on mobile (avoids iOS auto-zoom)
- `line-length-control` - Mobile 3560 chars per line; desktop 6075 chars
- `horizontal-scroll` - No horizontal scroll on mobile; ensure content fits viewport width
- `spacing-scale` - Use 4pt/8dp incremental spacing system (Material Design)
- `touch-density` - Keep component spacing comfortable for touch: not cramped, not causing mis-taps
- `container-width` - Consistent max-width on desktop (max-w-6xl / 7xl)
- `z-index-management` - Define layered z-index scale (e.g. 0 / 10 / 20 / 40 / 100 / 1000)
- `fixed-element-offset` - Fixed navbar/bottom bar must reserve safe padding for underlying content
- `scroll-behavior` - Avoid nested scroll regions that interfere with the main scroll experience
- `viewport-units` - Prefer min-h-dvh over 100vh on mobile
- `orientation-support` - Keep layout readable and operable in landscape mode
- `content-priority` - Show core content first on mobile; fold or hide secondary content
- `visual-hierarchy` - Establish hierarchy via size, spacing, contrast — not color alone
### 6. Typography & Color (MEDIUM)
- `line-height` - Use 1.5-1.75 for body text
- `line-length` - Limit to 65-75 characters per line
- `font-pairing` - Match heading/body font personalities
- `font-scale` - Consistent type scale (e.g. 12 14 16 18 24 32)
- `contrast-readability` - Darker text on light backgrounds (e.g. slate-900 on white)
- `text-styles-system` - Use platform type system: iOS 11 Dynamic Type styles / Material 5 type roles (display, headline, title, body, label) (HIG, MD)
- `weight-hierarchy` - Use font-weight to reinforce hierarchy: Bold headings (600700), Regular body (400), Medium labels (500) (MD)
- `color-semantic` - Define semantic color tokens (primary, secondary, error, surface, on-surface) not raw hex in components (Material color system)
- `color-dark-mode` - Dark mode uses desaturated / lighter tonal variants, not inverted colors; test contrast separately (HIG, MD)
- `color-accessible-pairs` - Foreground/background pairs must meet 4.5:1 (AA) or 7:1 (AAA); use tools to verify (WCAG, MD)
- `color-not-decorative-only` - Functional color (error red, success green) must include icon/text; avoid color-only meaning (HIG, MD)
- `truncation-strategy` - Prefer wrapping over truncation; when truncating use ellipsis and provide full text via tooltip/expand (Apple HIG)
- `letter-spacing` - Respect default letter-spacing per platform; avoid tight tracking on body text (HIG, MD)
- `number-tabular` - Use tabular/monospaced figures for data columns, prices, and timers to prevent layout shift
- `whitespace-balance` - Use whitespace intentionally to group related items and separate sections; avoid visual clutter (Apple HIG)
### 7. Animation (MEDIUM)
- `duration-timing` - Use 150300ms for micro-interactions; complex transitions ≤400ms; avoid >500ms (MD)
- `transform-performance` - Use transform/opacity only; avoid animating width/height/top/left
- `loading-states` - Show skeleton or progress indicator when loading exceeds 300ms
- `excessive-motion` - Animate 1-2 key elements per view max
- `easing` - Use ease-out for entering, ease-in for exiting; avoid linear for UI transitions
- `motion-meaning` - Every animation must express a cause-effect relationship, not just be decorative (Apple HIG)
- `state-transition` - State changes (hover / active / expanded / collapsed / modal) should animate smoothly, not snap
- `continuity` - Page/screen transitions should maintain spatial continuity (shared element, directional slide) (Apple HIG)
- `parallax-subtle` - Use parallax sparingly; must respect reduced-motion and not cause disorientation (Apple HIG)
- `spring-physics` - Prefer spring/physics-based curves over linear or cubic-bezier for natural feel (Apple HIG fluid animations)
- `exit-faster-than-enter` - Exit animations shorter than enter (~6070% of enter duration) to feel responsive (MD motion)
- `stagger-sequence` - Stagger list/grid item entrance by 3050ms per item; avoid all-at-once or too-slow reveals (MD)
- `shared-element-transition` - Use shared element / hero transitions for visual continuity between screens (MD, HIG)
- `interruptible` - Animations must be interruptible; user tap/gesture cancels in-progress animation immediately (Apple HIG)
- `no-blocking-animation` - Never block user input during an animation; UI must stay interactive (Apple HIG)
- `fade-crossfade` - Use crossfade for content replacement within the same container (MD)
- `scale-feedback` - Subtle scale (0.951.05) on press for tappable cards/buttons; restore on release (HIG, MD)
- `gesture-feedback` - Drag, swipe, and pinch must provide real-time visual response tracking the finger (MD Motion)
- `hierarchy-motion` - Use translate/scale direction to express hierarchy: enter from below = deeper, exit upward = back (MD)
- `motion-consistency` - Unify duration/easing tokens globally; all animations share the same rhythm and feel
- `opacity-threshold` - Fading elements should not linger below opacity 0.2; either fade fully or remain visible
- `modal-motion` - Modals/sheets should animate from their trigger source (scale+fade or slide-in) for spatial context (HIG, MD)
- `navigation-direction` - Forward navigation animates left/up; backward animates right/down — keep direction logically consistent (HIG)
- `layout-shift-avoid` - Animations must not cause layout reflow or CLS; use transform for position changes
### 8. Forms & Feedback (MEDIUM)
- `input-labels` - Visible label per input (not placeholder-only)
- `error-placement` - Show error below the related field
- `submit-feedback` - Loading then success/error state on submit
- `required-indicators` - Mark required fields (e.g. asterisk)
- `empty-states` - Helpful message and action when no content
- `toast-dismiss` - Auto-dismiss toasts in 3-5s
- `confirmation-dialogs` - Confirm before destructive actions
- `input-helper-text` - Provide persistent helper text below complex inputs, not just placeholder (Material Design)
- `disabled-states` - Disabled elements use reduced opacity (0.380.5) + cursor change + semantic attribute (MD)
- `progressive-disclosure` - Reveal complex options progressively; don't overwhelm users upfront (Apple HIG)
- `inline-validation` - Validate on blur (not keystroke); show error only after user finishes input (MD)
- `input-type-keyboard` - Use semantic input types (email, tel, number) to trigger the correct mobile keyboard (HIG, MD)
- `password-toggle` - Provide show/hide toggle for password fields (MD)
- `autofill-support` - Use autocomplete / textContentType attributes so the system can autofill (HIG, MD)
- `undo-support` - Allow undo for destructive or bulk actions (e.g. "Undo delete" toast) (Apple HIG)
- `success-feedback` - Confirm completed actions with brief visual feedback (checkmark, toast, color flash) (MD)
- `error-recovery` - Error messages must include a clear recovery path (retry, edit, help link) (HIG, MD)
- `multi-step-progress` - Multi-step flows show step indicator or progress bar; allow back navigation (MD)
- `form-autosave` - Long forms should auto-save drafts to prevent data loss on accidental dismissal (Apple HIG)
- `sheet-dismiss-confirm` - Confirm before dismissing a sheet/modal with unsaved changes (Apple HIG)
- `error-clarity` - Error messages must state cause + how to fix (not just "Invalid input") (HIG, MD)
- `field-grouping` - Group related fields logically (fieldset/legend or visual grouping) (MD)
- `read-only-distinction` - Read-only state should be visually and semantically different from disabled (MD)
- `focus-management` - After submit error, auto-focus the first invalid field (WCAG, MD)
- `error-summary` - For multiple errors, show summary at top with anchor links to each field (WCAG)
- `touch-friendly-input` - Mobile input height ≥44px to meet touch target requirements (Apple HIG)
- `destructive-emphasis` - Destructive actions use semantic danger color (red) and are visually separated from primary actions (HIG, MD)
- `toast-accessibility` - Toasts must not steal focus; use aria-live="polite" for screen reader announcement (WCAG)
- `aria-live-errors` - Form errors use aria-live region or role="alert" to notify screen readers (WCAG)
- `contrast-feedback` - Error and success state colors must meet 4.5:1 contrast ratio (WCAG, MD)
- `timeout-feedback` - Request timeout must show clear feedback with retry option (MD)
### 9. Navigation Patterns (HIGH)
- `bottom-nav-limit` - Bottom navigation max 5 items; use labels with icons (Material Design)
- `drawer-usage` - Use drawer/sidebar for secondary navigation, not primary actions (Material Design)
- `back-behavior` - Back navigation must be predictable and consistent; preserve scroll/state (Apple HIG, MD)
- `deep-linking` - All key screens must be reachable via deep link / URL for sharing and notifications (Apple HIG, MD)
- `tab-bar-ios` - iOS: use bottom Tab Bar for top-level navigation (Apple HIG)
- `top-app-bar-android` - Android: use Top App Bar with navigation icon for primary structure (Material Design)
- `nav-label-icon` - Navigation items must have both icon and text label; icon-only nav harms discoverability (MD)
- `nav-state-active` - Current location must be visually highlighted (color, weight, indicator) in navigation (HIG, MD)
- `nav-hierarchy` - Primary nav (tabs/bottom bar) vs secondary nav (drawer/settings) must be clearly separated (MD)
- `modal-escape` - Modals and sheets must offer a clear close/dismiss affordance; swipe-down to dismiss on mobile (Apple HIG)
- `search-accessible` - Search must be easily reachable (top bar or tab); provide recent/suggested queries (MD)
- `breadcrumb-web` - Web: use breadcrumbs for 3+ level deep hierarchies to aid orientation (MD)
- `state-preservation` - Navigating back must restore previous scroll position, filter state, and input (HIG, MD)
- `gesture-nav-support` - Support system gesture navigation (iOS swipe-back, Android predictive back) without conflict (HIG, MD)
- `tab-badge` - Use badges on nav items sparingly to indicate unread/pending; clear after user visits (HIG, MD)
- `overflow-menu` - When actions exceed available space, use overflow/more menu instead of cramming (MD)
- `bottom-nav-top-level` - Bottom nav is for top-level screens only; never nest sub-navigation inside it (MD)
- `adaptive-navigation` - Large screens (≥1024px) prefer sidebar; small screens use bottom/top nav (Material Adaptive)
- `back-stack-integrity` - Never silently reset the navigation stack or unexpectedly jump to home (HIG, MD)
- `navigation-consistency` - Navigation placement must stay the same across all pages; don't change by page type
- `avoid-mixed-patterns` - Don't mix Tab + Sidebar + Bottom Nav at the same hierarchy level
- `modal-vs-navigation` - Modals must not be used for primary navigation flows; they break the user's path (HIG)
- `focus-on-route-change` - After page transition, move focus to main content region for screen reader users (WCAG)
- `persistent-nav` - Core navigation must remain reachable from deep pages; don't hide it entirely in sub-flows (HIG, MD)
- `destructive-nav-separation` - Dangerous actions (delete account, logout) must be visually and spatially separated from normal nav items (HIG, MD)
- `empty-nav-state` - When a nav destination is unavailable, explain why instead of silently hiding it (MD)
### 10. Charts & Data (LOW)
- `chart-type` - Match chart type to data type (trend → line, comparison → bar, proportion → pie/donut)
- `color-guidance` - Use accessible color palettes; avoid red/green only pairs for colorblind users (WCAG, MD)
- `data-table` - Provide table alternative for accessibility; charts alone are not screen-reader friendly (WCAG)
- `pattern-texture` - Supplement color with patterns, textures, or shapes so data is distinguishable without color (WCAG, MD)
- `legend-visible` - Always show legend; position near the chart, not detached below a scroll fold (MD)
- `tooltip-on-interact` - Provide tooltips/data labels on hover (Web) or tap (mobile) showing exact values (HIG, MD)
- `axis-labels` - Label axes with units and readable scale; avoid truncated or rotated labels on mobile
- `responsive-chart` - Charts must reflow or simplify on small screens (e.g. horizontal bar instead of vertical, fewer ticks)
- `empty-data-state` - Show meaningful empty state when no data exists ("No data yet" + guidance), not a blank chart (MD)
- `loading-chart` - Use skeleton or shimmer placeholder while chart data loads; don't show an empty axis frame
- `animation-optional` - Chart entrance animations must respect prefers-reduced-motion; data should be readable immediately (HIG)
- `large-dataset` - For 1000+ data points, aggregate or sample; provide drill-down for detail instead of rendering all (MD)
- `number-formatting` - Use locale-aware formatting for numbers, dates, currencies on axes and labels (HIG, MD)
- `touch-target-chart` - Interactive chart elements (points, segments) must have ≥44pt tap area or expand on touch (Apple HIG)
- `no-pie-overuse` - Avoid pie/donut for >5 categories; switch to bar chart for clarity
- `contrast-data` - Data lines/bars vs background ≥3:1; data text labels ≥4.5:1 (WCAG)
- `legend-interactive` - Legends should be clickable to toggle series visibility (MD)
- `direct-labeling` - For small datasets, label values directly on the chart to reduce eye travel
- `tooltip-keyboard` - Tooltip content must be keyboard-reachable and not rely on hover alone (WCAG)
- `sortable-table` - Data tables must support sorting with aria-sort indicating current sort state (WCAG)
- `axis-readability` - Axis ticks must not be cramped; maintain readable spacing, auto-skip on small screens
- `data-density` - Limit information density per chart to avoid cognitive overload; split into multiple charts if needed
- `trend-emphasis` - Emphasize data trends over decoration; avoid heavy gradients/shadows that obscure the data
- `gridline-subtle` - Grid lines should be low-contrast (e.g. gray-200) so they don't compete with data
- `focusable-elements` - Interactive chart elements (points, bars, slices) must be keyboard-navigable (WCAG)
- `screen-reader-summary` - Provide a text summary or aria-label describing the chart's key insight for screen readers (WCAG)
- `error-state-chart` - Data load failure must show error message with retry action, not a broken/empty chart
- `export-option` - For data-heavy products, offer CSV/image export of chart data
- `drill-down-consistency` - Drill-down interactions must maintain a clear back-path and hierarchy breadcrumb
- `time-scale-clarity` - Time series charts must clearly label time granularity (day/week/month) and allow switching
## How to Use
Search specific domains using the CLI tool below.
---
## Prerequisites
Check if Python is installed:
```bash
python3 --version || python --version
```
If Python is not installed, install it based on user's OS:
**macOS:**
```bash
brew install python3
```
**Ubuntu/Debian:**
```bash
sudo apt update && sudo apt install python3
```
**Windows:**
```powershell
winget install Python.Python.3.12
```
> **Note:** On Windows, use `python` instead of `python3` to run scripts (e.g., `python scripts/search.py` instead of `python3 scripts/search.py`).
---
## How to Use This Skill
Use this skill when the user requests any of the following:
| Scenario | Trigger Examples | Start From |
|----------|-----------------|------------|
| **New project / page** | "Build a landing page", "Build a dashboard" | Step 1 → Step 2 (design system) |
| **New component** | "Create a pricing card", "Add a modal" | Step 3 (domain search: style, ux) |
| **Choose style / color / font** | "What style fits a fintech app?", "Recommend a color palette" | Step 2 (design system) |
| **Review existing UI** | "Review this page for UX issues", "Check accessibility" | Quick Reference checklist above |
| **Fix a UI bug** | "Button hover is broken", "Layout shifts on load" | Quick Reference → relevant section |
| **Improve / optimize** | "Make this faster", "Improve mobile experience" | Step 3 (domain search: ux, react) |
| **Implement dark mode** | "Add dark mode support" | Step 3 (domain: style "dark mode") |
| **Add charts / data viz** | "Add an analytics dashboard chart" | Step 3 (domain: chart) |
| **Stack best practices** | "React performance tips"、"SwiftUI navigation" | Step 4 (stack search) |
Follow this workflow:
### Step 1: Analyze User Requirements
Extract key information from user request:
- **Product type**: Entertainment (social, video, music, gaming), Tool (scanner, editor, converter), Productivity (task manager, notes, calendar), or hybrid
- **Target audience**: C-end consumer users; consider age group, usage context (commute, leisure, work)
- **Style keywords**: playful, vibrant, minimal, dark mode, content-first, immersive, etc.
- **Stack**: Match the project's framework. The engine ships guidance for many stacks (see [Available Stacks](#available-stacks) below) — pass the matching `--stack` (e.g. `nextjs`, `react`, `shadcn`, `vue`, `svelte`, `astro`, `swiftui`, `flutter`, `react-native`).
### Step 2: Generate Design System (REQUIRED)
**Always start with `--design-system`** to get comprehensive recommendations with reasoning:
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<product_type> <industry> <keywords>" --design-system [-p "Project Name"]
```
This command:
1. Searches domains in parallel (product, style, color, landing, typography)
2. Applies reasoning rules from `ui-reasoning.csv` to select best matches
3. Returns complete design system: pattern, style, colors, typography, effects
4. Includes anti-patterns to avoid
**Example:**
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "beauty spa wellness service" --design-system -p "Serenity Spa"
```
### Step 2b: Persist Design System (Master + Overrides Pattern)
To save the design system for **hierarchical retrieval across sessions**, add `--persist`:
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --persist -p "Project Name"
```
This creates:
- `design-system/MASTER.md` — Global Source of Truth with all design rules
- `design-system/pages/` — Folder for page-specific overrides
**With page-specific override:**
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --persist -p "Project Name" --page "dashboard"
```
This also creates:
- `design-system/pages/dashboard.md` — Page-specific deviations from Master
**How hierarchical retrieval works:**
1. When building a specific page (e.g., "Checkout"), first check `design-system/pages/checkout.md`
2. If the page file exists, its rules **override** the Master file
3. If not, use `design-system/MASTER.md` exclusively
**Context-aware retrieval prompt:**
```
I am building the [Page Name] page. Please read design-system/MASTER.md.
Also check if design-system/pages/[page-name].md exists.
If the page file exists, prioritize its rules.
If not, use the Master rules exclusively.
Now, generate the code...
```
### Step 2c: Design Dials (optional)
Three optional 1-10 sliders that tune `--design-system` output without changing your query. Add any combination of them to the same command:
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<query>" --design-system --variance <1-10> --motion <1-10> --density <1-10>
```
| Dial | Low (1-3) | Mid (4-7) | High (8-10) |
|------|-----------|-----------|-------------|
| `--variance` | Centered / minimal (biases toward Minimalism-style categories) | Balanced / modern | Bold / asymmetric (biases toward Brutalism, Bento Grids) |
| `--motion` | Subtle micro-interactions | Standard scroll/stagger motion | Complex choreography (pin, Flip, SplitText) |
| `--density` | Spacious (24-96px spacing scale) | Standard (16-64px, current default) | Dense/dashboard (8-32px spacing scale) |
- `--motion` attaches a ready-to-use GSAP snippet (with framework notes, Do/Don't, and performance notes) pulled from `--domain gsap`, matched to the resolved tier (Subtle/Standard/Complex).
- `--density` overrides the `--space-*` CSS variable table in the ASCII/markdown/MASTER.md output — use it for dashboards (high) vs. marketing pages (low) without hand-editing tokens.
- Leaving a dial unset keeps that part of the output exactly as it was before (no behavior change).
**Example:**
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "internal analytics dashboard" --design-system --variance 8 --motion 7 --density 8 -p "Ops Console"
```
### Step 3: Supplement with Detailed Searches (as needed)
After getting the design system, use domain searches to get additional details:
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --domain <domain> [-n <max_results>]
```
**When to use detailed searches:**
| Need | Domain | Example |
|------|--------|---------|
| Product type patterns | `product` | `--domain product "entertainment social"` |
| More style options | `style` | `--domain style "glassmorphism dark"` |
| Color palettes | `color` | `--domain color "entertainment vibrant"` |
| Font pairings | `typography` | `--domain typography "playful modern"` |
| Chart recommendations | `chart` | `--domain chart "real-time dashboard"` |
| UX best practices | `ux` | `--domain ux "animation accessibility"` |
| Alternative fonts | `typography` | `--domain typography "elegant luxury"` |
| Individual Google Fonts | `google-fonts` | `--domain google-fonts "sans serif popular variable"` |
| Landing structure | `landing` | `--domain landing "hero social-proof"` |
| React Native perf | `react` | `--domain react "rerender memo list"` |
| App interface a11y | `web` | `--domain web "accessibilityLabel touch safe-areas"` |
| AI prompt / CSS keywords | `prompt` | `--domain prompt "minimalism"` |
### Step 4: Stack Guidelines (match your framework)
Get implementation-specific best practices for the stack you're building in.
Pass the `--stack` that matches the project's framework:
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "<keyword>" --stack <your-stack>
# e.g. --stack nextjs | react | shadcn | vue | svelte | astro | swiftui | flutter | react-native
```
---
## Search Reference
### Available Domains
| Domain | Use For | Example Keywords |
|--------|---------|------------------|
| `product` | Product type recommendations | SaaS, e-commerce, portfolio, healthcare, beauty, service |
| `style` | UI styles, colors, effects | glassmorphism, minimalism, dark mode, brutalism |
| `typography` | Font pairings, Google Fonts | elegant, playful, professional, modern |
| `color` | Color palettes by product type | saas, ecommerce, healthcare, beauty, fintech, service |
| `landing` | Page structure, CTA strategies | hero, hero-centric, testimonial, pricing, social-proof |
| `chart` | Chart types, library recommendations | trend, comparison, timeline, funnel, pie |
| `ux` | Best practices, anti-patterns | animation, accessibility, z-index, loading |
| `gsap` | GSAP animation skeletons by intensity tier | scroll reveal, stagger, magnetic cursor, page transition |
| `google-fonts` | Individual Google Fonts lookup | sans serif, monospace, japanese, variable font, popular |
| `react` | React/Next.js performance | waterfall, bundle, suspense, memo, rerender, cache |
| `web` | App interface guidelines (iOS/Android/React Native) | accessibilityLabel, touch targets, safe areas, Dynamic Type |
| `prompt` | AI prompts, CSS keywords | (style name) |
### Available Stacks
Run `ls <skill>/data/stacks/` to see the live set. Shipped stacks:
| Stack | Focus |
|-------|-------|
| `react` | Components, hooks, render performance |
| `nextjs` | App Router, RSC, Server Actions, rendering |
| `vue` | Components, Composition API, reactivity |
| `nuxtjs` | Nuxt app patterns, SSR data fetching |
| `nuxt-ui` | Nuxt UI component patterns |
| `svelte` | Components, stores, transitions |
| `astro` | Islands, content, partial hydration |
| `shadcn` | shadcn/ui primitives, composition |
| `html-tailwind` | Tailwind utility patterns |
| `angular` | Components, signals, services |
| `laravel` | Blade / server-rendered UI patterns |
| `swiftui` | Views, state, navigation (iOS/macOS) |
| `flutter` | Widgets, state, navigation |
| `jetpack-compose` | Composables, state, navigation (Android) |
| `react-native` | Components, Navigation, Lists |
| `threejs` | 3D scenes, materials, performance |
---
## Example Workflow
**User request:** "Make an AI search homepage."
### Step 1: Analyze Requirements
- Product type: Tool (AI search engine)
- Target audience: C-end users looking for fast, intelligent search
- Style keywords: modern, minimal, content-first, dark mode
- Stack: Next.js (a homepage is a web surface; use a web `--stack`)
### Step 2: Generate Design System (REQUIRED)
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "AI search tool modern minimal" --design-system -p "AI Search"
```
**Output:** Complete design system with pattern, style, colors, typography, effects, and anti-patterns.
### Step 3: Supplement with Detailed Searches (as needed)
```bash
# Get style options for a modern tool product
python3 skills/ui-ux-pro-max/scripts/search.py "minimalism dark mode" --domain style
# Get UX best practices for search interaction and loading
python3 skills/ui-ux-pro-max/scripts/search.py "search loading animation" --domain ux
```
### Step 4: Stack Guidelines
```bash
python3 skills/ui-ux-pro-max/scripts/search.py "list performance navigation" --stack nextjs
```
**Then:** Synthesize design system + detailed searches and implement the design.
---
## Output Formats
The `--design-system` flag supports two output formats:
```bash
# ASCII box (default) - best for terminal display
python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system
# Markdown - best for documentation
python3 skills/ui-ux-pro-max/scripts/search.py "fintech crypto" --design-system -f markdown
```
---
## Tips for Better Results
### Query Strategy
- Use **multi-dimensional keywords** — combine product + industry + tone + density: `"entertainment social vibrant content-dense"` not just `"app"`
- Try different keywords for the same need: `"playful neon"``"vibrant dark"``"content-first minimal"`
- Use `--design-system` first for full recommendations, then `--domain` to deep-dive any dimension you're unsure about
- Add the `--stack` that matches the project's framework for implementation-specific guidance
### Common Sticking Points
| Problem | What to Do |
|---------|------------|
| Can't decide on style/color | Re-run `--design-system` with different keywords |
| Dark mode contrast issues | Quick Reference §6: `color-dark-mode` + `color-accessible-pairs` |
| Animations feel unnatural | Quick Reference §7: `spring-physics` + `easing` + `exit-faster-than-enter` |
| Form UX is poor | Quick Reference §8: `inline-validation` + `error-clarity` + `focus-management` |
| Navigation feels confusing | Quick Reference §9: `nav-hierarchy` + `bottom-nav-limit` + `back-behavior` |
| Layout breaks on small screens | Quick Reference §5: `mobile-first` + `breakpoint-consistency` |
| Performance / jank | Quick Reference §3: `virtualize-lists` + `main-thread-budget` + `debounce-throttle` |
### Pre-Delivery Checklist
- Run `--domain ux "animation accessibility z-index loading"` as a UX validation pass before implementation
- Run through Quick Reference **§1§3** (CRITICAL + HIGH) as a final review
- Test on 375px (small phone) and landscape orientation
- Verify behavior with **reduced-motion** enabled and **Dynamic Type** at largest size
- Check dark mode contrast independently (don't assume light mode values work)
- Confirm all touch targets ≥44pt and no content hidden behind safe areas
---
## Common Rules for Professional UI
These are frequently overlooked issues that make UI look unprofessional:
Scope notice: The rules below are for App UI (iOS/Android/React Native/Flutter), not desktop-web interaction patterns.
### Icons & Visual Elements
| Rule | Standard | Avoid | Why It Matters |
|------|----------|--------|----------------|
| **No Emoji as Structural Icons** | Use vector-based icons (e.g., Lucide, react-native-vector-icons, @expo/vector-icons). | Using emojis (🎨 🚀 ⚙️) for navigation, settings, or system controls. | Emojis are font-dependent, inconsistent across platforms, and cannot be controlled via design tokens. |
| **Vector-Only Assets** | Use SVG or platform vector icons that scale cleanly and support theming. | Raster PNG icons that blur or pixelate. | Ensures scalability, crisp rendering, and dark/light mode adaptability. |
| **Stable Interaction States** | Use color, opacity, or elevation transitions for press states without changing layout bounds. | Layout-shifting transforms that move surrounding content or trigger visual jitter. | Prevents unstable interactions and preserves smooth motion/perceived quality on mobile. |
| **Correct Brand Logos** | Use official brand assets and follow their usage guidelines (spacing, color, clear space). | Guessing logo paths, recoloring unofficially, or modifying proportions. | Prevents brand misuse and ensures legal/platform compliance. |
| **Consistent Icon Sizing** | Define icon sizes as design tokens (e.g., icon-sm, icon-md = 24pt, icon-lg). | Mixing arbitrary values like 20pt / 24pt / 28pt randomly. | Maintains rhythm and visual hierarchy across the interface. |
| **Stroke Consistency** | Use a consistent stroke width within the same visual layer (e.g., 1.5px or 2px). | Mixing thick and thin stroke styles arbitrarily. | Inconsistent strokes reduce perceived polish and cohesion. |
| **Filled vs Outline Discipline** | Use one icon style per hierarchy level. | Mixing filled and outline icons at the same hierarchy level. | Maintains semantic clarity and stylistic coherence. |
| **Touch Target Minimum** | Minimum 44×44pt interactive area (use hitSlop if icon is smaller). | Small icons without expanded tap area. | Meets accessibility and platform usability standards. |
| **Icon Alignment** | Align icons to text baseline and maintain consistent padding. | Misaligned icons or inconsistent spacing around them. | Prevents subtle visual imbalance that reduces perceived quality. |
| **Icon Contrast** | Follow WCAG contrast standards: 4.5:1 for small elements, 3:1 minimum for larger UI glyphs. | Low-contrast icons that blend into the background. | Ensures accessibility in both light and dark modes. |
### Interaction (App)
| Rule | Do | Don't |
|------|----|----- |
| **Tap feedback** | Provide clear pressed feedback (ripple/opacity/elevation) within 80-150ms | No visual response on tap |
| **Animation timing** | Keep micro-interactions around 150-300ms with platform-native easing | Instant transitions or slow animations (>500ms) |
| **Accessibility focus** | Ensure screen reader focus order matches visual order and labels are descriptive | Unlabeled controls or confusing focus traversal |
| **Disabled state clarity** | Use disabled semantics (`disabled`/native disabled props), reduced emphasis, and no tap action | Controls that look tappable but do nothing |
| **Touch target minimum** | Keep tap areas >=44x44pt (iOS) or >=48x48dp (Android), expand hit area when icon is smaller | Tiny tap targets or icon-only hit areas without padding |
| **Gesture conflict prevention** | Keep one primary gesture per region and avoid nested tap/drag conflicts | Overlapping gestures causing accidental actions |
| **Semantic native controls** | Prefer native interactive primitives (`Button`, `Pressable`, platform equivalents) with proper accessibility roles | Generic containers used as primary controls without semantics |
### Light/Dark Mode Contrast
| Rule | Do | Don't |
|------|----|----- |
| **Surface readability (light)** | Keep cards/surfaces clearly separated from background with sufficient opacity/elevation | Overly transparent surfaces that blur hierarchy |
| **Text contrast (light)** | Maintain body text contrast >=4.5:1 against light surfaces | Low-contrast gray body text |
| **Text contrast (dark)** | Maintain primary text contrast >=4.5:1 and secondary text >=3:1 on dark surfaces | Dark mode text that blends into background |
| **Border and divider visibility** | Ensure separators are visible in both themes (not just light mode) | Theme-specific borders disappearing in one mode |
| **State contrast parity** | Keep pressed/focused/disabled states equally distinguishable in light and dark themes | Defining interaction states for one theme only |
| **Token-driven theming** | Use semantic color tokens mapped per theme across app surfaces/text/icons | Hardcoded per-screen hex values |
| **Scrim and modal legibility** | Use a modal scrim strong enough to isolate foreground content (typically 40-60% black) | Weak scrim that leaves background visually competing |
### Layout & Spacing
| Rule | Do | Don't |
|------|----|----- |
| **Safe-area compliance** | Respect top/bottom safe areas for all fixed headers, tab bars, and CTA bars | Placing fixed UI under notch, status bar, or gesture area |
| **System bar clearance** | Add spacing for status/navigation bars and gesture home indicator | Let tappable content collide with OS chrome |
| **Consistent content width** | Keep predictable content width per device class (phone/tablet) | Mixing arbitrary widths between screens |
| **8dp spacing rhythm** | Use a consistent 4/8dp spacing system for padding/gaps/section spacing | Random spacing increments with no rhythm |
| **Readable text measure** | Keep long-form text readable on large devices (avoid edge-to-edge paragraphs on tablets) | Full-width long text that hurts readability |
| **Section spacing hierarchy** | Define clear vertical rhythm tiers (e.g., 16/24/32/48) by hierarchy | Similar UI levels with inconsistent spacing |
| **Adaptive gutters by breakpoint** | Increase horizontal insets on larger widths and in landscape | Same narrow gutter on all device sizes/orientations |
| **Scroll and fixed element coexistence** | Add bottom/top content insets so lists are not hidden behind fixed bars | Scroll content obscured by sticky headers/footers |
---
## Pre-Delivery Checklist
Before delivering UI code, verify these items:
Scope notice: This checklist is for App UI (iOS/Android/React Native/Flutter).
### Visual Quality
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons come from a consistent icon family and style
- [ ] Official brand assets are used with correct proportions and clear space
- [ ] Pressed-state visuals do not shift layout bounds or cause jitter
- [ ] Semantic theme tokens are used consistently (no ad-hoc per-screen hardcoded colors)
### Interaction
- [ ] All tappable elements provide clear pressed feedback (ripple/opacity/elevation)
- [ ] Touch targets meet minimum size (>=44x44pt iOS, >=48x48dp Android)
- [ ] Micro-interaction timing stays in the 150-300ms range with native-feeling easing
- [ ] Disabled states are visually clear and non-interactive
- [ ] Screen reader focus order matches visual order, and interactive labels are descriptive
- [ ] Gesture regions avoid nested/conflicting interactions (tap/drag/back-swipe conflicts)
### Light/Dark Mode
- [ ] Primary text contrast >=4.5:1 in both light and dark mode
- [ ] Secondary text contrast >=3:1 in both light and dark mode
- [ ] Dividers/borders and interaction states are distinguishable in both modes
- [ ] Modal/drawer scrim opacity is strong enough to preserve foreground legibility (typically 40-60% black)
- [ ] Both themes are tested before delivery (not inferred from a single theme)
### Layout
- [ ] Safe areas are respected for headers, tab bars, and bottom CTA bars
- [ ] Scroll content is not hidden behind fixed/sticky bars
- [ ] Verified on small phone, large phone, and tablet (portrait + landscape)
- [ ] Horizontal insets/gutters adapt correctly by device size and orientation
- [ ] 4/8dp spacing rhythm is maintained across component, section, and page levels
- [ ] Long-form text measure remains readable on larger devices (no edge-to-edge paragraphs)
### Accessibility
- [ ] All meaningful images/icons have accessibility labels
- [ ] Form fields have labels, hints, and clear error messages
- [ ] Color is not the only indicator
- [ ] Reduced motion and dynamic text size are supported without layout breakage
- [ ] Accessibility traits/roles/states (selected, disabled, expanded) are announced correctly
---
_Imported into astroagent from **ui-ux-pro-max-skill** by nextlevelbuilder_
_(github.com/nextlevelbuilder/ui-ux-pro-max-skill). The searchable CSV/script_
_engine is omitted (no shell in the sandbox); this is the guidance layer._

View file

@ -12,12 +12,7 @@ const site =
export default defineConfig({ export default defineConfig({
site, site,
// astroagent overrides outDir for isolated preview builds (PREVIEW_OUT); outDir: "../public",
// falls back to the live output for normal/cron builds.
outDir: process.env.PREVIEW_OUT || "../public",
// For previews, PREVIEW_BASE prefixes asset paths (/_preview/<id>) so the
// preview is self-contained and doesn't pull _astro/fonts from the live site.
base: process.env.PREVIEW_BASE || undefined,
integrations: [mdx()], integrations: [mdx()],
vite: { vite: {
plugins: [tailwindcss()], plugins: [tailwindcss()],

View file

@ -1,384 +0,0 @@
---
/**
* astroagent in-site console drawer — a multi-turn chat.
* Inject once in the base layout (before </body>). Ships to every visitor but
* stays hidden/inert unless /devconsole/ping reports the visitor is authed
* (token cookie). Real security is server-side on every endpoint; the DOM
* hiding is only UX. Unlock once by visiting any page with ?devkey=<TOKEN>.
*/
const route = "/devconsole";
const previewRoute = "/_preview";
---
<div id="aa-console" data-route={route} data-preview-route={previewRoute} hidden>
<button id="aa-handle" type="button" aria-label="Open developer console">▲ astroagent</button>
<section id="aa-panel" hidden aria-label="Developer console">
<header>
<span class="aa-title">astroagent</span>
<span id="aa-state" class="aa-state"></span>
<button id="aa-key" type="button" aria-label="Agent auth token" title="Set agent auth token">🔑</button>
<button id="aa-new" type="button" aria-label="New conversation" title="New conversation (clear)">✚</button>
<button id="aa-lock" type="button" aria-label="Lock console" title="Lock (hide until next devkey)">🔒</button>
<button id="aa-close" type="button" aria-label="Collapse">▼</button>
</header>
<div id="aa-chat" aria-live="polite"></div>
<div id="aa-actions" hidden>
<a id="aa-preview" href="#" target="_blank" rel="noopener">Open preview ↗</a>
<button id="aa-publish" type="button">Publish</button>
</div>
<div id="aa-picks" hidden></div>
<form id="aa-form">
<button id="aa-pick" type="button" aria-label="Select an element on the page" title="Select an element on the page (Esc to cancel)">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" aria-hidden="true"><circle cx="12" cy="12" r="6.5"></circle><path d="M12 1.5v4M12 18.5v4M1.5 12h4M18.5 12h4"></path></svg>
</button>
<textarea id="aa-prompt" rows="2" placeholder="Ask or change anything — “what pages do I have?”, “make the buttons green”, then “now make them bigger”"></textarea>
<button id="aa-run" type="submit">Send</button>
</form>
</section>
<div id="aa-hl" hidden><span id="aa-hl-tag"></span></div>
<div id="aa-authmodal" hidden>
<div class="aa-modal">
<div class="aa-modal-h">Agent sign-in required</div>
<p class="aa-modal-p">The agent's Claude session was lost. Paste a long-lived token — generate one on the server with <code>claude setup-token</code>.</p>
<input id="aa-authinput" type="password" placeholder="Paste auth token" autocomplete="off" spellcheck="false" />
<div id="aa-authmsg" class="aa-modal-msg"></div>
<div class="aa-modal-btns">
<button id="aa-authcancel" type="button">Cancel</button>
<button id="aa-authsave" type="button">Save &amp; verify</button>
</div>
</div>
</div>
</div>
<style>
#aa-console { position: fixed; right: 16px; bottom: 16px; z-index: 2147483000; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
#aa-handle { background:#111; color:#e6e6e6; border:1px solid #333; border-radius:8px; padding:8px 12px; font-size:12px; cursor:pointer; box-shadow:0 4px 16px rgba(0,0,0,.3); }
#aa-handle:hover { background:#1b1b1b; }
#aa-panel { width: min(700px, calc(100vw - 32px)); height: min(64vh, 560px); background:#0c0c0d; color:#d6d6d6; border:1px solid #2a2a2a; border-radius:10px; display:flex; flex-direction:column; box-shadow:0 12px 40px rgba(0,0,0,.5); overflow:hidden; }
#aa-panel header { display:flex; align-items:center; gap:8px; padding:8px 10px; background:#141416; border-bottom:1px solid #2a2a2a; font-size:12px; }
#aa-panel .aa-title { font-weight:600; color:#fff; }
#aa-panel .aa-state { margin-left:auto; color:#8a8a8a; font-size:11px; }
#aa-close, #aa-lock, #aa-key, #aa-new { background:none; border:none; color:#8a8a8a; cursor:pointer; font-size:12px; padding:2px 4px; }
#aa-lock:hover, #aa-close:hover, #aa-key:hover, #aa-new:hover { color:#fff; }
#aa-chat { flex:1; overflow:auto; padding:12px; display:flex; flex-direction:column; gap:8px; }
.aa-msg { max-width:86%; padding:8px 11px; border-radius:9px; font-size:12px; line-height:1.5; white-space:pre-wrap; word-break:break-word; }
.aa-user { align-self:flex-end; background:rgba(31,111,235,.22); border:1px solid rgba(31,111,235,.45); color:#dbeafe; }
.aa-bot { align-self:flex-start; background:#161b22; border:1px solid #2a2a2a; color:#d6d6d6; }
.aa-bot .aa-tool { display:block; color:#6cb6ff; opacity:.75; font-size:11px; }
.aa-bot .aa-txt { display:block; margin:2px 0; }
.aa-bot .aa-emsg { color:#ff7b72; }
.aa-sys { align-self:center; color:#8a8a8a; font-size:11px; }
.aa-sys.ok { color:#7ee787; }
#aa-actions { display:flex; gap:8px; align-items:center; padding:8px 10px; border-top:1px solid #2a2a2a; }
#aa-actions a { color:#6cb6ff; font-size:12px; text-decoration:none; margin-right:auto; }
#aa-form { display:flex; gap:8px; padding:10px; border-top:1px solid #2a2a2a; }
#aa-prompt { flex:1; resize:none; background:#141416; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:6px; padding:8px; font:inherit; font-size:12px; }
#aa-panel button:not(#aa-close):not(#aa-lock):not(#aa-key):not(#aa-new):not(#aa-pick):not(.aa-x) { background:#238636; color:#fff; border:none; border-radius:6px; padding:8px 14px; font-size:12px; cursor:pointer; }
#aa-panel button:disabled { opacity:.5; cursor:default; }
#aa-pick { display:flex; align-items:center; justify-content:center; background:#141416; color:#8a8a8a; border:1px solid #2a2a2a; border-radius:6px; padding:0 11px; cursor:pointer; }
#aa-pick:hover { color:#fff; border-color:#3a3a3a; }
#aa-pick.on { background:#1f6feb; color:#fff; border-color:#1f6feb; }
#aa-handle.aa-picking { background:#1f6feb; border-color:#1f6feb; color:#fff; animation:aa-pulse 1.6s ease-in-out infinite; }
@keyframes aa-pulse { 0%,100% { box-shadow:0 4px 16px rgba(31,111,235,.35); } 50% { box-shadow:0 4px 24px rgba(31,111,235,.8); } }
@media (prefers-reduced-motion: reduce) { #aa-handle.aa-picking { animation:none; } }
#aa-picks { display:flex; flex-direction:column; gap:6px; padding:8px 10px; border-top:1px solid #2a2a2a; max-height:130px; overflow:auto; }
.aa-chip { display:flex; align-items:center; gap:8px; background:#161b22; border:1px solid #2a2a2a; border-radius:7px; padding:6px 8px; }
.aa-chip-tag { color:#6cb6ff; font-size:11px; white-space:nowrap; max-width:150px; overflow:hidden; text-overflow:ellipsis; flex-shrink:0; }
.aa-chip input { flex:1; background:#0c0c0d; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:5px; padding:5px 7px; font:inherit; font-size:11px; min-width:0; }
.aa-x { background:none; border:none; color:#8a8a8a; cursor:pointer; font-size:13px; padding:0 3px; flex-shrink:0; }
.aa-x:hover { color:#ff7b72; }
#aa-hl { position:fixed; pointer-events:none; z-index:2147482998; background:rgba(31,111,235,.14); border:1.5px solid rgba(31,111,235,.9); border-radius:3px; }
#aa-hl-tag { position:absolute; top:-20px; left:-1.5px; background:#1f6feb; color:#fff; font-size:10px; font-family:ui-monospace, Menlo, monospace; padding:2px 6px; border-radius:3px; white-space:nowrap; }
#aa-authmodal { position:fixed; inset:0; background:rgba(0,0,0,.55); display:flex; align-items:center; justify-content:center; z-index:2147483001; }
#aa-authmodal .aa-modal { background:#141416; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:10px; padding:18px; width:min(440px, calc(100vw - 32px)); box-shadow:0 12px 40px rgba(0,0,0,.6); }
.aa-modal-h { font-weight:600; color:#fff; margin-bottom:6px; }
.aa-modal-p { font-size:12px; color:#9a9a9a; margin:0 0 12px; line-height:1.5; }
.aa-modal-p code { color:#6cb6ff; }
#aa-authinput { width:100%; box-sizing:border-box; background:#0c0c0d; color:#e6e6e6; border:1px solid #2a2a2a; border-radius:6px; padding:9px; font:inherit; font-size:12px; }
.aa-modal-msg { font-size:12px; min-height:16px; margin:8px 0; }
.aa-modal-btns { display:flex; gap:8px; justify-content:flex-end; }
#aa-authsave { background:#238636; color:#fff; border:none; border-radius:6px; padding:8px 14px; font-size:12px; cursor:pointer; }
#aa-authcancel { background:#30363d !important; color:#fff; border:none; border-radius:6px; padding:8px 14px; font-size:12px; cursor:pointer; }
</style>
<script>
const root = document.getElementById("aa-console");
const route = root.dataset.route;
const $ = (id) => document.getElementById(id);
const api = (p) => route + p;
async function boot() {
const devkey = new URLSearchParams(location.search).get("devkey");
let authed = false;
try {
const r = await fetch(api("/ping") + (devkey ? "?key=" + encodeURIComponent(devkey) : ""), { credentials: "same-origin" });
authed = (await r.json()).authed;
} catch { authed = false; }
if (!authed) { root.remove(); return; }
root.hidden = false;
wire();
}
function wire() {
const handle = $("aa-handle"), panel = $("aa-panel");
const chat = $("aa-chat"), stateEl = $("aa-state"), actions = $("aa-actions");
const previewLink = $("aa-preview"), runBtn = $("aa-run"), promptEl = $("aa-prompt");
let convId = null, es = null, bot = null, running = false;
const open = () => { panel.hidden = false; handle.hidden = true; promptEl.focus(); };
const close = () => { panel.hidden = true; handle.hidden = false; };
handle.onclick = open; $("aa-close").onclick = close;
$("aa-lock").onclick = async () => {
try { await fetch(api("/logout"), { method:"POST", credentials:"same-origin" }); } catch {}
root.remove();
};
const scroll = () => { chat.scrollTop = chat.scrollHeight; };
const setState = (s) => (stateEl.textContent = s || "");
const busy = (b) => { running = b; runBtn.disabled = b; pickBtn.disabled = b; };
function bubble(cls) { const d = document.createElement("div"); d.className = "aa-msg " + cls; chat.appendChild(d); scroll(); return d; }
function userMsg(t) { const d = bubble("aa-user"); d.textContent = t; }
function sysMsg(t, ok) { const d = bubble("aa-sys" + (ok ? " ok" : "")); d.textContent = t; }
function botStart() { bot = bubble("aa-bot"); return bot; }
function botText(t) { if (!bot) botStart(); const s = document.createElement("span"); s.className = "aa-txt"; s.textContent = t; bot.appendChild(s); scroll(); }
function botTool(t) { if (!bot) botStart(); const s = document.createElement("span"); s.className = "aa-tool"; s.textContent = "· " + t; bot.appendChild(s); scroll(); }
function botErr(t) { if (!bot) botStart(); const s = document.createElement("span"); s.className = "aa-txt aa-emsg"; s.textContent = "✗ " + t; bot.appendChild(s); scroll(); }
function ensureStream() {
if (es || !convId) return;
es = new EventSource(api("/stream") + "?conversationId=" + encodeURIComponent(convId));
es.onmessage = (m) => {
let ev; try { ev = JSON.parse(m.data); } catch { return; }
switch (ev.type) {
case "turn_start": setState("working…"); break;
case "progress":
if (ev.kind === "text") botText(ev.text);
else if (ev.kind === "tool") botTool(ev.text);
else if (ev.kind === "log") setState("building preview…");
break; // ignore 'done' (duplicate of final text)
case "preview":
previewLink.href = ev.url; actions.hidden = false;
sysMsg("✓ preview updated", true);
break;
case "turn_end": setState(""); busy(false); bot = null; break;
case "published":
sysMsg("✓ published — " + (ev.commit || "").slice(0,8) + " (live)", true); actions.hidden = true;
sysMsg("reloading to show the live site…");
// The published event fires after the prod build, so the live site is
// ready. From a preview page, go to the live equivalent of this route.
setTimeout(() => {
const live = stripPreview(location.pathname);
if (live !== location.pathname) location.href = live;
else location.reload();
}, 1200);
break;
case "auth_required": setState(""); busy(false); botErr("agent not signed in"); openAuth(); break;
case "error": setState(""); busy(false); botErr(ev.text || "error"); break;
case "end": es && es.close(); es = null; convId = null; busy(false); setState(""); break;
}
};
es.onerror = () => { /* keep-alive; browser auto-reconnects */ };
}
$("aa-form").onsubmit = async (e) => {
e.preventDefault();
if (running) return;
const message = promptEl.value.trim();
const selections = picks.map((p) => ({ ...p.data, comment: p.input.value.trim() }));
if (!message && !selections.length) return;
promptEl.value = "";
const shown = [message, ...picks.map((p) => "⌖ " + p.label + (p.input.value.trim() ? " — " + p.input.value.trim() : ""))].filter(Boolean).join("\n");
userMsg(shown); botStart(); busy(true); setState("working…");
try {
const body = convId ? { conversationId: convId, message } : { message };
if (selections.length) body.selections = selections;
const r = await fetch(api("/run"), {
method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" },
body: JSON.stringify(body),
});
const d = await r.json();
if (!r.ok) throw new Error(d.error || "run failed");
convId = d.conversationId;
clearPicks();
ensureStream();
} catch (err) { botErr(err.message); busy(false); setState(""); }
};
$("aa-publish").onclick = async () => {
if (!convId || running) return;
setState("publishing…");
try {
const r = await fetch(api("/publish"), { method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" }, body: JSON.stringify({ conversationId: convId }) });
const d = await r.json();
if (!r.ok) throw new Error(d.error || "publish failed");
} catch (err) { sysMsg("✗ " + err.message); }
setState("");
};
$("aa-new").onclick = async () => {
if (running) return;
try { await fetch(api("/discard"), { method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" }, body: JSON.stringify({ conversationId: convId }) }); } catch {}
if (es) { es.close(); es = null; }
convId = null; bot = null; chat.textContent = ""; actions.hidden = true; setState("");
clearPicks();
sysMsg("new conversation");
promptEl.focus();
};
// Enter to send, Shift+Enter for newline.
promptEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); $("aa-form").requestSubmit(); }
});
// --- visual element picker: click an element on the page, attach a comment ---
const pickBtn = $("aa-pick"), picksEl = $("aa-picks"), hl = $("aa-hl"), hlTag = $("aa-hl-tag");
const prevRoute = root.dataset.previewRoute || "/_preview";
const MAX_PICKS = 5;
let picking = false, picks = [], lastTarget = null;
const isOurs = (t) => t instanceof Element && t.closest("#aa-console");
// "/_preview/<jobId>/about/" -> "/about/" (no-op on live pages)
const stripPreview = (p) => p.replace(new RegExp("^" + prevRoute.replace(/[.*+?^${}()|[\]\\]/g, "\\$&") + "/[^/]+"), "") || "/";
function hlOn(el) {
const r = el.getBoundingClientRect();
hl.style.left = r.left + "px"; hl.style.top = r.top + "px";
hl.style.width = r.width + "px"; hl.style.height = r.height + "px";
hlTag.textContent = el.tagName.toLowerCase() + (el.classList[0] ? "." + el.classList[0] : "");
hl.hidden = false;
}
function onMove(e) {
const t = e.target;
if (!(t instanceof Element) || isOurs(t)) { hl.hidden = true; lastTarget = null; return; }
lastTarget = t; hlOn(t);
}
function onScroll() { if (picking && lastTarget) hlOn(lastTarget); }
function onKey(e) { if (e.key === "Escape") { exitPick(); open(); } }
function onClick(e) {
const t = e.target;
if (isOurs(t)) { exitPick(); return; } // our own UI (the handle) — cancel, let the click through
e.preventDefault(); e.stopImmediatePropagation();
exitPick();
addPick(t);
open();
}
function enterPick() {
if (picking || running) return;
if (picks.length >= MAX_PICKS) { sysMsg("max " + MAX_PICKS + " selections per turn"); return; }
picking = true; pickBtn.classList.add("on");
close(); // collapse to the handle so the panel doesn't cover the page
handle.textContent = "click any element — Esc cancels";
handle.classList.add("aa-picking");
document.body.style.cursor = "crosshair";
document.addEventListener("mousemove", onMove, true);
document.addEventListener("click", onClick, true);
document.addEventListener("keydown", onKey, true);
document.addEventListener("scroll", onScroll, { passive: true });
}
function exitPick() {
if (!picking) return;
picking = false; pickBtn.classList.remove("on");
handle.textContent = "▲ astroagent";
handle.classList.remove("aa-picking");
hl.hidden = true; lastTarget = null;
document.body.style.cursor = "";
document.removeEventListener("mousemove", onMove, true);
document.removeEventListener("click", onClick, true);
document.removeEventListener("keydown", onKey, true);
document.removeEventListener("scroll", onScroll);
}
pickBtn.onclick = () => { if (picking) { exitPick(); open(); } else enterPick(); };
// Serialized at pick time from the BUILT page DOM. Class attributes and visible
// text survive Astro's production build verbatim — they're the agent's grep keys.
function cssPath(el) {
const parts = [];
let n = el;
for (let d = 0; n && n.nodeType === 1 && n.tagName !== "HTML" && d < 8; d++) {
const tag = n.tagName.toLowerCase();
if (n.id) { parts.unshift(tag + "#" + n.id); break; }
const cls = [...n.classList].filter((c) => !c.startsWith("astro-")).slice(0, 2);
let sel = tag + cls.map((c) => "." + c).join("");
const sibs = n.parentElement ? [...n.parentElement.children].filter((s) => s.tagName === n.tagName) : [];
if (sibs.length > 1) sel += ":nth-of-type(" + (sibs.indexOf(n) + 1) + ")";
parts.unshift(sel);
if (tag === "body") break;
n = n.parentElement;
}
return parts.join(" > ");
}
function ancestorChain(el) {
const out = [];
for (let n = el.parentElement; n && n.tagName !== "HTML" && out.length < 6; n = n.parentElement) {
out.unshift(n.tagName.toLowerCase() + [...n.classList].filter((c) => !c.startsWith("astro-")).slice(0, 2).map((c) => "." + c).join(""));
}
return out;
}
function trimHtml(el) {
const c = el.cloneNode(true);
for (const x of c.querySelectorAll("#aa-console")) x.remove();
for (const s of c.querySelectorAll("svg")) s.replaceChildren();
for (const n of [c, ...c.querySelectorAll("*")]) {
for (const a of [...n.attributes]) if (a.value.length > 150) n.setAttribute(a.name, a.value.slice(0, 150) + "…");
}
let h = c.outerHTML;
if (h.length > 2000) h = h.slice(0, 2000) + "…";
return h;
}
function capture(el) {
const r = el.getBoundingClientRect();
return {
route: stripPreview(location.pathname),
selector: cssPath(el),
classes: el.getAttribute("class") || "",
text: (el.innerText || "").trim().replace(/\s+/g, " ").slice(0, 200),
html: trimHtml(el),
ancestors: ancestorChain(el),
rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) },
};
}
function addPick(el) {
const data = capture(el);
const label = el.tagName.toLowerCase() + (data.text ? " “" + data.text.slice(0, 24) + (data.text.length > 24 ? "…" : "") + "”" : (el.classList[0] ? "." + el.classList[0] : ""));
const chip = document.createElement("div"); chip.className = "aa-chip";
const tag = document.createElement("span"); tag.className = "aa-chip-tag"; tag.textContent = label; tag.title = data.selector;
const input = document.createElement("input"); input.placeholder = "What should change here?"; input.maxLength = 500;
const x = document.createElement("button"); x.type = "button"; x.className = "aa-x"; x.textContent = "×"; x.setAttribute("aria-label", "Remove selection");
chip.append(tag, input, x);
const pick = { el, data, label, input, chip };
x.onclick = () => { picks = picks.filter((p) => p !== pick); chip.remove(); picksEl.hidden = !picks.length; };
tag.onmouseenter = () => { if (pick.el && pick.el.isConnected) hlOn(pick.el); };
tag.onmouseleave = () => { hl.hidden = true; };
input.addEventListener("keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); $("aa-form").requestSubmit(); } });
picks.push(pick);
picksEl.appendChild(chip); picksEl.hidden = false;
input.focus();
}
function clearPicks() { picks = []; picksEl.textContent = ""; picksEl.hidden = true; }
// --- agent auth token modal (pops up on auth loss, or via the 🔑 button) ---
const authModal = $("aa-authmodal"), authInput = $("aa-authinput"), authMsg = $("aa-authmsg");
const openAuth = () => { authMsg.textContent = ""; authInput.value = ""; authModal.hidden = false; authInput.focus(); };
const closeAuth = () => { authModal.hidden = true; };
$("aa-key").onclick = openAuth;
$("aa-authcancel").onclick = closeAuth;
$("aa-authsave").onclick = async () => {
const token = authInput.value.trim();
if (!token) return;
authMsg.style.color = "#9a9a9a"; authMsg.textContent = "verifying…";
try {
const r = await fetch(api("/auth"), { method:"POST", credentials:"same-origin", headers:{ "content-type":"application/json" }, body: JSON.stringify({ token }) });
const d = await r.json();
if (d.ok) { authMsg.style.color = "#7ee787"; authMsg.textContent = "✓ saved & verified — send your message again"; setTimeout(closeAuth, 1300); }
else { authMsg.style.color = "#ff7b72"; authMsg.textContent = "✗ " + (d.error || "failed"); }
} catch (err) { authMsg.style.color = "#ff7b72"; authMsg.textContent = "✗ " + err.message; }
};
// expose for the stream handler
window.__aaOpenAuth = openAuth;
}
boot();
</script>

View file

@ -2,7 +2,6 @@
import "../styles.css"; import "../styles.css";
import Header from "../components/Header.astro"; import Header from "../components/Header.astro";
import Footer from "../components/Footer.astro"; import Footer from "../components/Footer.astro";
import DevConsole from "../components/DevConsole.astro";
import { SITE, imageSrc } from "../lib/blog-data.js"; import { SITE, imageSrc } from "../lib/blog-data.js";
const { const {
@ -90,6 +89,5 @@ const ogImageUrl = absoluteUrl(ogImage);
</main> </main>
<Footer flush={flushFooter} /> <Footer flush={flushFooter} />
</div> </div>
<DevConsole />
</body> </body>
</html> </html>

View file

@ -1,29 +0,0 @@
{
"name": "seedproject-site",
"url": "https://example.com",
"appDir": "app",
"outDir": "public",
"buildCommand": "npm run build",
"contentDir": "app/src/content/blog",
"deploy": {
"type": "local-apache",
"chown": "www:www"
},
"ai": {
"model": "claude-sonnet-4-6",
"tools": "Read Write Edit Glob Grep WebSearch",
"confine": true,
"agentUser": "seedproject-site-agent",
"skills": [
"brand",
"ui-ux",
"seo"
]
},
"console": {
"port": 3011,
"route": "/devconsole",
"tokenFile": "agents/.env",
"tokenKey": "ADMIN_TOKEN"
}
}

View file

@ -7,12 +7,8 @@
* *
* Writes: * Writes:
* - app/src/config/site.json (Astro theme reads this) * - app/src/config/site.json (Astro theme reads this)
* - agents/config.json (site identity + author + timezone)
* - astroagent.config.json (name + url)
*
* Finishes by regenerating the agent catalog (agents/AGENTS.md + agents.json).
*/ */
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs"; import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { dirname, resolve } from "node:path"; import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -24,7 +20,6 @@ const write = (p, obj) => {
writeFileSync(full, JSON.stringify(obj, null, 2) + "\n"); writeFileSync(full, JSON.stringify(obj, null, 2) + "\n");
console.log(" wrote", p); console.log(" wrote", p);
}; };
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "");
const site = read("site.config.json"); const site = read("site.config.json");
console.log(`Configuring site: ${site.name} <${site.url}>`); console.log(`Configuring site: ${site.name} <${site.url}>`);
@ -40,30 +35,4 @@ write("app/src/config/site.json", {
social: site.social, social: site.social,
}); });
// 2) agents
if (existsSync(resolve(root, "agents/config.json"))) {
const cp = read("agents/config.json");
cp.site = { ...cp.site, url: site.url, name: site.name, topic: site.topic, audience: site.audience, language: site.language };
cp.author = { ...(cp.author || {}), slug: site.author.slug, name: site.author.name };
if (cp.paths) cp.paths.projectRoot = root; // this clone's absolute path, not comiida's
if (cp.editorial) cp.editorial.timezone = site.timezone;
if (cp.image && cp.image.credit) cp.image.credit.author = site.name;
write("agents/config.json", cp);
}
// 3) astroagent
if (existsSync(resolve(root, "astroagent.config.json"))) {
const aa = read("astroagent.config.json");
aa.name = slug(site.name);
aa.url = site.url;
if (aa.ai) aa.ai.agentUser = `${slug(site.name)}-agent`;
write("astroagent.config.json", aa);
}
// 4) agent catalog (AGENTS.md + agents.json reflect this clone's identity)
if (existsSync(resolve(root, "agents/catalog.mjs"))) {
const { execFileSync } = await import("node:child_process");
execFileSync("node", [resolve(root, "agents/catalog.mjs")], { stdio: "inherit" });
}
console.log("Done. Rebuild the site: (cd app && npm run build)"); console.log("Done. Rebuild the site: (cd app && npm run build)");

View file

@ -5,9 +5,8 @@
# Usage (from the repo root, after cloning): # Usage (from the repo root, after cloning):
# ./scripts/new-site.sh # ./scripts/new-site.sh
# #
# Prompts for this site's identity, writes site.config.json, stamps it across # Prompts for this site's identity, writes site.config.json, stamps it into
# every engine (Astro theme, agents, astroagent), optionally resets # the Astro theme, optionally resets git history and installs dependencies.
# git history and installs dependencies.
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
set -euo pipefail set -euo pipefail
@ -28,8 +27,6 @@ NAME="$(ask 'Site name' "$(get .name)")"
URL="$(ask 'Site URL' "$(get .url)")" URL="$(ask 'Site URL' "$(get .url)")"
DESC="$(ask 'One-line description' "$(get .description)")" DESC="$(ask 'One-line description' "$(get .description)")"
TAG="$(ask 'Tagline' "$(get .tagline)")" TAG="$(ask 'Tagline' "$(get .tagline)")"
TOPIC="$(ask 'Topic / niche' "$(get .topic)")"
AUD="$(ask 'Audience' "$(get .audience)")"
TZ="$(ask 'Timezone' "$(get .timezone)")" TZ="$(ask 'Timezone' "$(get .timezone)")"
AUTHOR="$(ask 'Author name' "$(get '.author.name')")" AUTHOR="$(ask 'Author name' "$(get '.author.name')")"
TW="$(ask 'Twitter/X URL (optional)' "$(get '.social.twitter')")" TW="$(ask 'Twitter/X URL (optional)' "$(get '.social.twitter')")"
@ -39,19 +36,19 @@ SLUG="$(printf '%s' "$AUTHOR" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]
echo echo
echo "Writing site.config.json ..." echo "Writing site.config.json ..."
NAME="$NAME" URL="$URL" DESC="$DESC" TAG="$TAG" TOPIC="$TOPIC" AUD="$AUD" TZ="$TZ" AUTHOR="$AUTHOR" SLUG="$SLUG" TW="$TW" \ NAME="$NAME" URL="$URL" DESC="$DESC" TAG="$TAG" TZ="$TZ" AUTHOR="$AUTHOR" SLUG="$SLUG" TW="$TW" \
node -e ' node -e '
const fs = require("fs"); const fs = require("fs");
const c = JSON.parse(fs.readFileSync("site.config.json", "utf8")); const c = JSON.parse(fs.readFileSync("site.config.json", "utf8"));
const e = process.env; const e = process.env;
c.name = e.NAME; c.url = e.URL; c.description = e.DESC; c.tagline = e.TAG; c.name = e.NAME; c.url = e.URL; c.description = e.DESC; c.tagline = e.TAG;
c.topic = e.TOPIC; c.audience = e.AUD; c.timezone = e.TZ; c.timezone = e.TZ;
c.author = { ...c.author, name: e.AUTHOR, slug: e.SLUG }; c.author = { ...c.author, name: e.AUTHOR, slug: e.SLUG };
c.social = { ...c.social, twitter: e.TW }; c.social = { ...c.social, twitter: e.TW };
fs.writeFileSync("site.config.json", JSON.stringify(c, null, 2) + "\n"); fs.writeFileSync("site.config.json", JSON.stringify(c, null, 2) + "\n");
' '
echo "Stamping identity across engines ..." echo "Stamping identity into the theme ..."
node scripts/configure.mjs node scripts/configure.mjs
read -r -p "Reset git history for this new site? [y/N]: " RESETGIT || true read -r -p "Reset git history for this new site? [y/N]: " RESETGIT || true

View file

@ -5,8 +5,6 @@
"tagline": "A starter site built on SeedProject", "tagline": "A starter site built on SeedProject",
"language": "en", "language": "en",
"timezone": "America/New_York", "timezone": "America/New_York",
"topic": "your subject area",
"audience": "your target audience",
"author": { "author": {
"slug": "site-author", "slug": "site-author",
"name": "Site Author", "name": "Site Author",