From e13c0b9e19af0cc30f8085cba322a69491e1f138 Mon Sep 17 00:00:00 2001 From: Carlos Arias Date: Fri, 24 Jul 2026 10:54:51 -0500 Subject: [PATCH] chore: remove proprietary agent framework (agents/ + astroagent) from seed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) Claude-Session: https://claude.ai/code/session_01DfzaSFv5okUxCCdvq5RXu1 --- .gitignore | 21 +- AGENTS.md | 33 +- README.md | 26 +- agents/AGENTS.md | 32 -- agents/README.md | 134 ----- agents/admin/server.mjs | 456 ----------------- agents/agents.json | 153 ------ agents/catalog.mjs | 98 ---- agents/config.json | 89 ---- agents/drafts/.gitkeep | 0 agents/logs/.gitkeep | 0 agents/prompts/image.md | 37 -- agents/prompts/news-radar.system.md | 53 -- agents/prompts/research.system.md | 56 -- agents/prompts/reviser.system.md | 40 -- agents/prompts/seo-review.system.md | 103 ---- agents/prompts/writer.system.md | 104 ---- agents/run.sh | 20 - agents/scripts/approve.mjs | 78 --- agents/scripts/lib/blogData.mjs | 68 --- agents/scripts/lib/build.mjs | 38 -- agents/scripts/lib/calendar.mjs | 95 ---- agents/scripts/lib/claude.mjs | 71 --- agents/scripts/lib/git.mjs | 37 -- agents/scripts/lib/lock.mjs | 46 -- agents/scripts/lib/publish.mjs | 84 --- agents/scripts/lib/util.mjs | 84 --- agents/scripts/list-drafts.mjs | 54 -- agents/scripts/news-radar.mjs | 128 ----- agents/scripts/publish-tick.mjs | 117 ----- agents/scripts/research.mjs | 110 ---- agents/scripts/revise.mjs | 107 ---- agents/scripts/seo-review.mjs | 128 ----- agents/scripts/write-daily.mjs | 215 -------- agents/state/.gitkeep | 0 api/.memory/foundation.md | 4 +- api/cli/resources.php | 38 -- app/.astroagent/skills/ui-ux/SKILL.md | 711 -------------------------- app/astro.config.mjs | 7 +- app/src/components/DevConsole.astro | 384 -------------- app/src/layouts/BaseLayout.astro | 2 - astroagent.config.json | 29 -- scripts/configure.mjs | 33 +- scripts/new-site.sh | 13 +- site.config.json | 2 - 45 files changed, 24 insertions(+), 4114 deletions(-) delete mode 100644 agents/AGENTS.md delete mode 100644 agents/README.md delete mode 100644 agents/admin/server.mjs delete mode 100644 agents/agents.json delete mode 100644 agents/catalog.mjs delete mode 100644 agents/config.json delete mode 100644 agents/drafts/.gitkeep delete mode 100644 agents/logs/.gitkeep delete mode 100644 agents/prompts/image.md delete mode 100644 agents/prompts/news-radar.system.md delete mode 100644 agents/prompts/research.system.md delete mode 100644 agents/prompts/reviser.system.md delete mode 100644 agents/prompts/seo-review.system.md delete mode 100644 agents/prompts/writer.system.md delete mode 100755 agents/run.sh delete mode 100644 agents/scripts/approve.mjs delete mode 100644 agents/scripts/lib/blogData.mjs delete mode 100644 agents/scripts/lib/build.mjs delete mode 100644 agents/scripts/lib/calendar.mjs delete mode 100644 agents/scripts/lib/claude.mjs delete mode 100644 agents/scripts/lib/git.mjs delete mode 100644 agents/scripts/lib/lock.mjs delete mode 100644 agents/scripts/lib/publish.mjs delete mode 100644 agents/scripts/lib/util.mjs delete mode 100644 agents/scripts/list-drafts.mjs delete mode 100644 agents/scripts/news-radar.mjs delete mode 100644 agents/scripts/publish-tick.mjs delete mode 100644 agents/scripts/research.mjs delete mode 100644 agents/scripts/revise.mjs delete mode 100644 agents/scripts/seo-review.mjs delete mode 100644 agents/scripts/write-daily.mjs delete mode 100644 agents/state/.gitkeep delete mode 100644 api/cli/resources.php delete mode 100644 app/.astroagent/skills/ui-ux/SKILL.md delete mode 100644 app/src/components/DevConsole.astro delete mode 100644 astroagent.config.json diff --git a/.gitignore b/.gitignore index 3db0404..0c0bd70 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,6 @@ # ============================================================================ # 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. # ============================================================================ @@ -23,22 +23,3 @@ app/.astro/ # --- logs --- *.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/ diff --git a/AGENTS.md b/AGENTS.md index 6d37377..4f96a46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -29,13 +29,12 @@ on the site afterward. ## The golden rule: one identity source `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. - 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 - `agents/config.json` and `astroagent.config.json`. + That regenerates `app/src/config/site.json` (the theme reads it). - 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`). @@ -48,7 +47,6 @@ name, URL, description, tagline, author, social, topic, audience, timezone. | `scripts/configure.mjs` | Stamp `site.config.json` into every engine | | `app/` | Astro frontend ("theme"). Build → `../public`. See `app/AGENTS.md` for coding standards. | | `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) | ## Backend (`api/`) @@ -70,24 +68,9 @@ php console db:migrate --status - Web-server setup (aliasing `/api` → `api/` with PHP-FPM + front-controller rewrite): see `api/.memory/foundation.md`. -## Content pipeline & authoring +## Authoring content -- `agents/` runs the autonomous content system. Each agent script carries an - `@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//index.mdx` following the +- To add a post: create `app/src/content/blog//index.mdx` following the schema in `app/src/content.config.js` (copy the sample `welcome` post as a template). ## Local development @@ -106,12 +89,6 @@ Read this before relying on any subsystem: - ⚠️ **`/api` Foundation** — code is written and code-verified (composer, `php console`, autoload, graceful CLI failures), but the **live DB + HTTP round-trip is UNVERIFIED** (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 diff --git a/README.md b/README.md index 17dc513..a3963a2 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,16 @@ # SeedProject base 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 -pipeline. Clone it, set one config file, and build out a new site (a lawyer's -office, a roofing company, a niche publication) the way you'd spin up a new -WordPress install — but faster to host and safer to run. +frontend with a PHP backend. Clone it, set one config file, and build out a new +site (a lawyer's office, a roofing company, a niche publication) the way you'd spin +up a new WordPress install — but faster to host and safer to run. ## What's inside | Path | Role | "WordPress equivalent" | |------|------|------------------------| | `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 | -| `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 | +| `api/` | [SeedProject](api/.memory/documentation.md) PHP framework — the dynamic backend (DB, forms, metrics) | PHP core | | `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 @@ -54,9 +51,8 @@ After editing it, run: node scripts/configure.mjs ``` -This stamps the values into the Astro theme (`app/src/config/site.json`), -`agents/config.json`, and `astroagent.config.json` so all three engines -share one identity. `new-site.sh` runs it for you. +This stamps the values into the Astro theme (`app/src/config/site.json`). +`new-site.sh` runs it for you. ## 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) > and [`api/.memory/foundation-plan.md`](api/.memory/foundation-plan.md). -## Content pipeline & authoring +## Authoring content -- **`agents/`** — autonomous research/write/review/publish scripts driven - by `agents/config.json` (populated from `site.config.json`). Runtime - 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`. +Add a post by creating `app/src/content/blog//index.mdx` following the +schema in `app/src/content.config.js` (copy the sample `welcome` post as a template). ## Directory layout @@ -94,7 +87,6 @@ my-site/ │ └── configure.mjs ← stamp site.config.json into every engine ├── app/ ← Astro frontend (build → ../public) ├── api/ ← SeedProject PHP backend (served at /api) -├── agents/ ← autonomous content engine └── public/ ← build output (git-ignored) ``` diff --git a/agents/AGENTS.md b/agents/AGENTS.md deleted file mode 100644 index 42179d4..0000000 --- a/agents/AGENTS.md +++ /dev/null @@ -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 (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 (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 [--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. | diff --git a/agents/README.md b/agents/README.md deleted file mode 100644 index 2d86912..0000000 --- a/agents/README.md +++ /dev/null @@ -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//` — 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// (auto-runs the SEO audit after). -./run.sh write-daily.mjs [slug] - -# SEO Specialist audit (EEAT / spam-policy / on-page / AEO / readability). -# Writes /seo-review.json. Default audits the draft; --published audits the live post. -./run.sh seo-review.mjs [--published] - -# Auto-revise a failing draft until it passes the SEO gate (or maxReviseAttempts). -./run.sh revise.mjs - -# 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 [--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=`** (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 `. - -## 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 --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. diff --git a/agents/admin/server.mjs b/agents/admin/server.mjs deleted file mode 100644 index 29a2698..0000000 --- a/agents/admin/server.mjs +++ /dev/null @@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c])); - -const STATUS_COLORS = { - planned: "#8a8f98", - drafting: "#d98324", - drafted: "#2f6feb", - "drafted-no-image": "#b58900", - "draft-failed": "#cb2431", - published: "#1a7f37", -}; -const badge = (status) => - `${esc(status)}`; - -const newsBadge = `NEWS`; -const suggestedBadge = `SUGGESTED`; -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 - ? `SEO ${esc(seo.verdict)} ${esc(seo.overall)}` - : `SEO —`; - -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) => ` - - ${esc(e.date)} - ${esc(e.workingTitle || e.slug)} ${e.suggested ? suggestedBadge : ""}${e.news ? " " + newsBadge : ""}
${esc(e.primaryKeyword || "")} - ${esc(e.type)} - ${badge(isPublished(e.slug) ? "published" : e.status)} - ${ - existsSync(join(draftsDir(), e.slug, "index.mdx")) - ? `preview` - : isPublished(e.slug) - ? `live ↗` - : "—" - } - `; - - const draftCards = drafts.length - ? drafts - .map( - (d) => ` -
- ${d.hasCover ? `` : `
`} -
-
${esc(d.title)} ${d.news ? newsBadge : ""} ${seoBadge(d.seo)}
-
${esc(d.date)} · ${esc(d.category)} · ${esc(d.tags.join(", "))}
- ${d.seo?.blocking?.length ? `
⚠ ${esc(d.seo.blocking.length)} blocking: ${esc(d.seo.blocking[0])}${d.seo.blocking.length > 1 ? " …" : ""}
` : ""} -
preview · approve.mjs ${esc(d.slug)}
-
-
` - ) - .join("") - : `

No drafts awaiting review.

`; - - const countPills = Object.entries(counts) - .map(([k, v]) => `${badge(k)} ${v}`) - .join("  "); - - const msgs = messages().slice().reverse(); - const msgHtml = msgs.length - ? msgs - .map( - (m) => ` -
- -
${esc(m.name)} <${esc(m.email)}> · ${esc((m.at || "").slice(0, 16).replace("T", " "))}
-
${esc(m.message)}
-
` - ) - .join("") - : `

No messages yet.

`; - - return ` -Comiida — Content Admin -
-
-

Comiida — Content Admin

- -
-
Today ${esc(today)} (${esc(cfg.editorial.timezone)}) · ${countPills} · 📰 news queue: ${freshNewsCount()} fresh
- -
- -
- - -

📬 Messages (${msgs.length})

-
${msgHtml}
- - -

This week (${esc(today)} → ${esc(weekEnd)})

- ${inWeek.length ? `${inWeek.map(row).join("")}
DateTopicTypeStatus
` : `

Nothing scheduled this week. Run research.mjs to plan more.

`} - -

Drafts awaiting review (${drafts.length})

-
${draftCards}
- -

Full calendar (${cal.length})

- ${cal.map(row).join("")}
DateTopicTypeStatus
-
`; -} - -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 - ? `

SEO audit ${seoBadge(seo)}

-

${esc(seo.summary || "")}

-
    ${Object.entries(seo.dimensions || {}) - .map(([k, v]) => `
  • ${esc(k)}: ${esc(v.score)}${v.issues?.length ? " — " + esc(v.issues.join("; ")) : ""}
  • `) - .join("")}
- ${seo.blocking?.length ? `

Blocking:

    ${seo.blocking.map((b) => `
  • ${esc(b)}
  • `).join("")}
` : ""} - ${seo.topFixes?.length ? `

Top fixes:

    ${seo.topFixes.map((f) => `
  • [${esc(f.severity)}/${esc(f.area)}] ${esc(f.fix)}
  • `).join("")}
` : ""}` - : `

SEO audit ${seoBadge(null)}

No audit yet. Run ./run.sh write-daily.mjs (auto-audits) or node scripts/seo-review.mjs ${esc(slug)}.

`; - const hasCover = existsSync(join(dir, "cover.jpg")); - return ` -${esc(fmString(fm, "title") || slug)} — draft -
-

← back to dashboard

- ${hasCover ? `` : ""} - ${seoSection} -

Frontmatter

${esc(fm)}
-

Body (raw MDX)

${esc(body)}
-

sources.json

${esc(sources)}
-

To publish: ./run.sh approve.mjs ${esc(slug)}

-
`; -} - -// --- 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, "

401

Add ?key=YOUR_TOKEN to the URL.

"); - } - 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, "

404

"); - } - - // dashboard - if (path === "/admin" || path === "/admin/") { - return send(res, 200, dashboardHtml(), "text/html; charset=utf-8", cookieHeader); - } - - return send(res, 404, "

404

"); -}); - -server.listen(PORT, "127.0.0.1", () => console.log(`[admin] listening on 127.0.0.1:${PORT}`)); diff --git a/agents/agents.json b/agents/agents.json deleted file mode 100644 index 4875a4f..0000000 --- a/agents/agents.json +++ /dev/null @@ -1,153 +0,0 @@ -{ - "agents": [ - { - "file": "agents/scripts/approve.mjs", - "name": "approve", - "title": "Draft Approver", - "class": "plumbing", - "trigger": "manual [--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 (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 (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" - } - ] -} diff --git a/agents/catalog.mjs b/agents/catalog.mjs deleted file mode 100644 index fe2f01b..0000000 --- a/agents/catalog.mjs +++ /dev/null @@ -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`); diff --git a/agents/config.json b/agents/config.json deleted file mode 100644 index c272ddd..0000000 --- a/agents/config.json +++ /dev/null @@ -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 - ] - } - } -} diff --git a/agents/drafts/.gitkeep b/agents/drafts/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/agents/logs/.gitkeep b/agents/logs/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/agents/prompts/image.md b/agents/prompts/image.md deleted file mode 100644 index c2b8bde..0000000 --- a/agents/prompts/image.md +++ /dev/null @@ -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 "" -o "/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. diff --git a/agents/prompts/news-radar.system.md b/agents/prompts/news-radar.system.md deleted file mode 100644 index 4157745..0000000 --- a/agents/prompts/news-radar.system.md +++ /dev/null @@ -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 ~2–3 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": "", - "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` 1–5 = 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. diff --git a/agents/prompts/research.system.md b/agents/prompts/research.system.md deleted file mode 100644 index b3bc341..0000000 --- a/agents/prompts/research.system.md +++ /dev/null @@ -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. diff --git a/agents/prompts/reviser.system.md b/agents/prompts/reviser.system.md deleted file mode 100644 index bea3a08..0000000 --- a/agents/prompts/reviser.system.md +++ /dev/null @@ -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 - ~50–60 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. diff --git a/agents/prompts/seo-review.system.md b/agents/prompts/seo-review.system.md deleted file mode 100644 index f6c57ea..0000000 --- a/agents/prompts/seo-review.system.md +++ /dev/null @@ -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 " 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 " 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, ~50–60 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 0–100. 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 `/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. diff --git a/agents/prompts/writer.system.md b/agents/prompts/writer.system.md deleted file mode 100644 index 56f42eb..0000000 --- a/agents/prompts/writer.system.md +++ /dev/null @@ -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 - " 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 ", - "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 " 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//`. - -**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: # ~ words / 220, rounded, min 1 -category: "..." # one of the site categories (see blog-data.js); lowercase slug -tags: ["...", "..."] # 2-5 kebab-case tags -author: "" # 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 ---- - -
-``` -- `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. diff --git a/agents/run.sh b/agents/run.sh deleted file mode 100755 index 87ad9f9..0000000 --- a/agents/run.sh +++ /dev/null @@ -1,20 +0,0 @@ -#!/usr/bin/env bash -# Wrapper so cron has a sane PATH and the secrets from .env. -# Usage: run.sh [args...] -# run.sh research.mjs -# run.sh write-daily.mjs -# run.sh approve.mjs -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}" diff --git a/agents/scripts/approve.mjs b/agents/scripts/approve.mjs deleted file mode 100644 index 1497bad..0000000 --- a/agents/scripts/approve.mjs +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env node -/** - * @agent-manifest - * name: approve - * title: Draft Approver - * class: plumbing - * trigger: manual [--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 [--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 [--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); -} diff --git a/agents/scripts/lib/blogData.mjs b/agents/scripts/lib/blogData.mjs deleted file mode 100644 index 50530e4..0000000 --- a/agents/scripts/lib/blogData.mjs +++ /dev/null @@ -1,68 +0,0 @@ -import { readFileSync, writeFileSync } from "node:fs"; - -/** Find the [ ... ] range of `export const = [ ... ]`. */ -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; -} diff --git a/agents/scripts/lib/build.mjs b/agents/scripts/lib/build.mjs deleted file mode 100644 index 440433a..0000000 --- a/agents/scripts/lib/build.mjs +++ /dev/null @@ -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}`); - } - }); -} diff --git a/agents/scripts/lib/calendar.mjs b/agents/scripts/lib/calendar.mjs deleted file mode 100644 index eebe187..0000000 --- a/agents/scripts/lib/calendar.mjs +++ /dev/null @@ -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; - }); -} diff --git a/agents/scripts/lib/claude.mjs b/agents/scripts/lib/claude.mjs deleted file mode 100644 index 85573b6..0000000 --- a/agents/scripts/lib/claude.mjs +++ /dev/null @@ -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 }); - } - }); - }); -} diff --git a/agents/scripts/lib/git.mjs b/agents/scripts/lib/git.mjs deleted file mode 100644 index 89b4fc3..0000000 --- a/agents/scripts/lib/git.mjs +++ /dev/null @@ -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; - } -} diff --git a/agents/scripts/lib/lock.mjs b/agents/scripts/lib/lock.mjs deleted file mode 100644 index 42e6fff..0000000 --- a/agents/scripts/lib/lock.mjs +++ /dev/null @@ -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} fn critical section - * @param {{timeoutMs?: number, staleMs?: number}} [opts] - * @returns {Promise} - */ -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 */ - } - } -} diff --git a/agents/scripts/lib/publish.mjs b/agents/scripts/lib/publish.mjs deleted file mode 100644 index 6561fa7..0000000 --- a/agents/scripts/lib/publish.mjs +++ /dev/null @@ -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 }; -} diff --git a/agents/scripts/lib/util.mjs b/agents/scripts/lib/util.mjs deleted file mode 100644 index 5c7aeff..0000000 --- a/agents/scripts/lib/util.mjs +++ /dev/null @@ -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); -} diff --git a/agents/scripts/list-drafts.mjs b/agents/scripts/list-drafts.mjs deleted file mode 100644 index 3a50d3e..0000000 --- a/agents/scripts/list-drafts.mjs +++ /dev/null @@ -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`); -} diff --git a/agents/scripts/news-radar.mjs b/agents/scripts/news-radar.mjs deleted file mode 100644 index 885be0b..0000000 --- a/agents/scripts/news-radar.mjs +++ /dev/null @@ -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(); -} diff --git a/agents/scripts/publish-tick.mjs b/agents/scripts/publish-tick.mjs deleted file mode 100644 index 4d460b6..0000000 --- a/agents/scripts/publish-tick.mjs +++ /dev/null @@ -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); -} diff --git a/agents/scripts/research.mjs b/agents/scripts/research.mjs deleted file mode 100644 index 3f2a06f..0000000 --- a/agents/scripts/research.mjs +++ /dev/null @@ -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 }); -} diff --git a/agents/scripts/revise.mjs b/agents/scripts/revise.mjs deleted file mode 100644 index 13af100..0000000 --- a/agents/scripts/revise.mjs +++ /dev/null @@ -1,107 +0,0 @@ -#!/usr/bin/env node -/** - * @agent-manifest - * name: reviser - * title: Draft Reviser - * class: content - * trigger: manual (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 -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 "); - process.exit(1); - } - const { audit } = await runRevise(slug); - process.exit(audit && audit.verdict === "pass" ? 0 : 2); -} diff --git a/agents/scripts/seo-review.mjs b/agents/scripts/seo-review.mjs deleted file mode 100644 index 53f9504..0000000 --- a/agents/scripts/seo-review.mjs +++ /dev/null @@ -1,128 +0,0 @@ -#!/usr/bin/env node -/** - * @agent-manifest - * name: seo-review - * title: SEO Reviewer - * class: content - * trigger: manual (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 /seo-review.json. Usage: -// node scripts/seo-review.mjs [--published] -// (default: review the draft in agents/drafts/; --published reviews the -// live post in app/src/content/blog/) -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 [--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); -} diff --git a/agents/scripts/write-daily.mjs b/agents/scripts/write-daily.mjs deleted file mode 100644 index d6cc0ee..0000000 --- a/agents/scripts/write-daily.mjs +++ /dev/null @@ -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//. -// 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 ~800–1000 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}`); -} diff --git a/agents/state/.gitkeep b/agents/state/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/api/.memory/foundation.md b/api/.memory/foundation.md index 00e4f9a..9ff65d1 100644 --- a/api/.memory/foundation.md +++ b/api/.memory/foundation.md @@ -113,8 +113,8 @@ should be deleted/rotated by the operator. Reuses the existing tables — no duplication. - **`ApiController`** (base, privileged) — verifies a bearer token: either the - `ADMIN_TOKEN` (config constant; same token pattern as `/devconsole` & `/admin`) - for first-party/admin calls, **or** an `api_auth.apikey` for programmatic + `ADMIN_TOKEN` (config constant) for first-party/admin calls, **or** an + `api_auth.apikey` for programmatic consumers. On an API key: check `active`, enforce `api_plans.limit_minute` / `limit_monthly` against `api_usage`, and log to `api_requests`. Failures → `401` (missing/invalid) or `429` (over limit). diff --git a/api/cli/resources.php b/api/cli/resources.php deleted file mode 100644 index f28192a..0000000 --- a/api/cli/resources.php +++ /dev/null @@ -1,38 +0,0 @@ - -> JSON array of active resources - * php api/cli/resources.php touch [...] -> 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 \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 | touch \n"); -exit(1); diff --git a/app/.astroagent/skills/ui-ux/SKILL.md b/app/.astroagent/skills/ui-ux/SKILL.md deleted file mode 100644 index 343d5d2..0000000 --- a/app/.astroagent/skills/ui-ux/SKILL.md +++ /dev/null @@ -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 ` 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 < 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 < 12px body, Gray-on-gray, Raw hex in components | -| 7 | Animation | MEDIUM | `ux` | Duration 150–300ms, 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 (2–4px; 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 35–60 chars per line; desktop 60–75 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 (600–700), 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 150–300ms 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 (~60–70% of enter duration) to feel responsive (MD motion) -- `stagger-sequence` - Stagger list/grid item entrance by 30–50ms 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.95–1.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.38–0.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 " " --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 "" --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 "" --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 "" --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 "" --domain [-n ] -``` - -**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 "" --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 /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._ diff --git a/app/astro.config.mjs b/app/astro.config.mjs index 8247250..eed6e3d 100644 --- a/app/astro.config.mjs +++ b/app/astro.config.mjs @@ -12,12 +12,7 @@ const site = export default defineConfig({ site, - // astroagent overrides outDir for isolated preview builds (PREVIEW_OUT); - // falls back to the live output for normal/cron builds. - outDir: process.env.PREVIEW_OUT || "../public", - // For previews, PREVIEW_BASE prefixes asset paths (/_preview/) so the - // preview is self-contained and doesn't pull _astro/fonts from the live site. - base: process.env.PREVIEW_BASE || undefined, + outDir: "../public", integrations: [mdx()], vite: { plugins: [tailwindcss()], diff --git a/app/src/components/DevConsole.astro b/app/src/components/DevConsole.astro deleted file mode 100644 index 9b891c9..0000000 --- a/app/src/components/DevConsole.astro +++ /dev/null @@ -1,384 +0,0 @@ ---- -/** - * astroagent in-site console drawer — a multi-turn chat. - * Inject once in the base layout (before ). 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=. - */ -const route = "/devconsole"; -const previewRoute = "/_preview"; ---- - - - - - - diff --git a/app/src/layouts/BaseLayout.astro b/app/src/layouts/BaseLayout.astro index 6651c1b..56c8217 100644 --- a/app/src/layouts/BaseLayout.astro +++ b/app/src/layouts/BaseLayout.astro @@ -2,7 +2,6 @@ import "../styles.css"; import Header from "../components/Header.astro"; import Footer from "../components/Footer.astro"; -import DevConsole from "../components/DevConsole.astro"; import { SITE, imageSrc } from "../lib/blog-data.js"; const { @@ -90,6 +89,5 @@ const ogImageUrl = absoluteUrl(ogImage);