[object Object]

← back to The Ai Factory

[overnight] pre-debate baseline

75e44f63f3952cb1272d51fdd99cafb82f81cc8b · 2026-05-04 01:08:09 -0700 · SteveStudio2

Files touched

Diff

commit 75e44f63f3952cb1272d51fdd99cafb82f81cc8b
Author: SteveStudio2 <stevestudio2@SteveStacStudio.lan>
Date:   Mon May 4 01:08:09 2026 -0700

    [overnight] pre-debate baseline
---
 .env.example                                 |   15 +
 .gitignore                                   |    4 +
 CHANGES.md                                   |   14 +
 GA_CONSOLIDATION.md                          |   75 ++
 PLAN.md                                      |  117 +++
 README.md                                    |   28 +
 output/clickthrough/01-table.png             |  Bin 0 -> 255857 bytes
 output/clickthrough/02-events-expanded.png   |  Bin 0 -> 480415 bytes
 output/clickthrough/summary.json             |   10 +
 output/run-4/shopify-cost-breakdown/SKILL.md |   65 ++
 output/run-5/sql-tldr-summarizer/SKILL.md    |   40 +
 output/run-6/pm2-crash-watcher.md            |   48 +
 package-lock.json                            | 1293 ++++++++++++++++++++++++++
 package.json                                 |   21 +
 public/index.html                            |  150 +++
 scripts/clickthrough.js                      |   57 ++
 scripts/run.js                               |   23 +
 server.js                                    |  201 ++++
 sql/001_init.sql                             |   47 +
 src/llm.js                                   |   72 ++
 src/pipeline.js                              |  179 ++++
 src/stages/critic.js                         |   57 ++
 src/stages/document.js                       |   42 +
 src/stages/intake.js                         |   42 +
 src/stages/iterate.js                        |   39 +
 src/stages/memory_write.js                   |   53 ++
 src/stages/research.js                       |   63 ++
 src/stages/scaffold.js                       |   93 ++
 src/stages/smoke_test.js                     |   35 +
 viewer.js                                    |   15 +
 30 files changed, 2898 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..f5f607a
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,15 @@
+# The AI Factory — env template
+# Copy to .env and fill in real values.
+
+ANTHROPIC_API_KEY=
+
+PG_HOST=/tmp
+PG_PORT=5432
+PG_DATABASE=ai_factory
+PG_USER=stevestudio2
+PG_PASSWORD=
+
+ORCHESTRATOR_PORT=9890
+VIEWER_PORT=9891
+ADMIN_TOKEN=
+CLAUDE_CLI=claude
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..daf1dd0
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,4 @@
+node_modules/
+.env
+*.log
+.DS_Store
diff --git a/CHANGES.md b/CHANGES.md
new file mode 100644
index 0000000..91db73c
--- /dev/null
+++ b/CHANGES.md
@@ -0,0 +1,14 @@
+## Applied
+- `src/llm.js:10` — replaced the user-specific absolute Claude CLI fallback path with `claude` — removes a hardcoded `/Users/...` path while preserving env override via `CLAUDE_CLI`.
+- `.env.example:15` — added `CLAUDE_CLI=claude` placeholder — documents the configurable CLI path without introducing a real secret or machine-local path.
+
+## Deferred (needs Steve)
+- P0 — `server.js:31`, `server.js:67`, `server.js:95` — no auth plus wildcard CORS allows browser-origin localhost POSTs — deferred because adding token auth/CORS policy is outside the safe-fix list and changes operator workflow.
+- P0 — `server.js:95`, `server.js:104`, `server.js:143` — activation can copy generated files into `~/.claude` and `?force=true` bypasses approval — deferred because removing force or adding local confirmation/diff changes activation semantics.
+- P0 — `src/stages/scaffold.js:16`, `src/stages/smoke_test.js:20` — generated subagents may include dangerous tools and smoke tests only verify presence — deferred because safe remediation requires a frontmatter parser and artifact-specific allowlist design.
+- P1 — `server.js:78` — fire-and-forget pipeline can leave runs stuck after a process crash — deferred because a resumable worker/stale-job recovery is a behavioral change beyond the safe list.
+- P1 — `src/stages/memory_write.js:32` — read-modify-write append to `MEMORY.md` can race under concurrent activations — deferred because lock/atomic append behavior needs concurrency testing.
+- P1 — `src/pipeline.js:52`, `src/llm.js:51` — no concurrency limit around Ollama/Claude CLI — deferred because queue sizing and backpressure policy need Steve’s decision.
+- P1 — `README.md:17`, `package.json:8` — docs say `npm run start` boots orchestrator and viewer, but it only starts the orchestrator — deferred because docs/public-facing command behavior changes were not in the safe-fix list.
+- P1 — `PLAN.md:106` — completed items are duplicated as open TODOs — deferred because plan/status cleanup is not in the safe-fix list.
+- P1 — `package.json:15`, `.env.example:4`, `src/llm.js:5` — Anthropic SDK/API key remain despite “No Anthropic API” — deferred because dependency/env removal is not a safe autonomous fix and may require package-lock decisions.
diff --git a/GA_CONSOLIDATION.md b/GA_CONSOLIDATION.md
new file mode 100644
index 0000000..db0d287
--- /dev/null
+++ b/GA_CONSOLIDATION.md
@@ -0,0 +1,75 @@
+# GA4 Fleet Consolidation — Process Plan
+
+**Started:** 2026-04-30
+**Owner:** Steve
+**Process .md per `feedback_suggest_process_md.md`**
+
+---
+
+## Goal
+
+Bring every GA4 property visible to `steve@designerwallcoverings.com` under **one** GA account.
+
+## Locked decisions
+
+| # | Decision | Value | Why |
+|---|---|---|---|
+| Q1 | Move vs recreate | **recreate** | Fast, scriptable. Lose history; new G-IDs everywhere. |
+| Q2 | Scope | **literal — all properties, business + personal** | Steve: "yes all under steve@dw" (2026-04-30) |
+| Q3 | Target account | **`371997142` (IntelligentHome)** *(working assumption — flag if wrong)* | Default from URL Steve shared; renameable later |
+| Q4 | Inventory auth | **oneshot-adc** | Env-var-scoped to a single audit run; no `_auth.py` change. SA-per-account is too slow for the audit's purpose. |
+
+Decisions still owed before **Phase 4** (the destructive phase):
+- **Q3 confirm/replace** — is IntelligentHome really where everything should land, or should we create a fresh "Steve Personal" account?
+- **DW + studentdebtcrisis carve-outs** — analytics SKILL.md operating rule #1 already excludes these from injection. For *consolidation*, treat any DW-Shopify or SDCC GA property as DO-NOT-MOVE on sight.
+- **Cleanup policy for OLD properties** in source accounts after recreate: delete / archive / leave-dormant?
+
+## Phases
+
+### Phase 1 — Permission *(in progress)*
+- Inventory uses **oneshot ADC** (Steve's identity) → no per-account SA grants needed.
+- Service account stays on the bench unless we hit a write where SA scoping is preferred.
+
+### Phase 2 — Inventory *(blocked on Phase 1)*
+- `GA_USE_USER_AUTH=1 python3 ~/.claude/skills/analytics/scripts/audit_fleet.py`
+- Output: full grouped JSON of every account / property / web stream visible.
+
+### Phase 3 — Plan *(after inventory)*
+- Build a per-property table in this file: `KEEP_IN_PLACE / RECREATE_IN_TARGET / ARCHIVE / MERGE`.
+- Pre-flag DW-Shopify GA + SDCC + any client/partner properties as `KEEP_IN_PLACE`.
+- Steve reviews the table line-by-line before Phase 4.
+
+### Phase 4 — Execute (recreate path) *(human-gated, batched)*
+- For each `RECREATE_IN_TARGET` property:
+  1. Use Admin API (`create_property.py`) to create new property in account `371997142`.
+  2. Add web data stream with same `default_uri` as source.
+  3. Capture new `G-XXXXXXXX` measurement ID into a recreated→source mapping JSON.
+- **Never auto-execute the full batch.** Steve approves each batch (5–10 properties) explicitly.
+
+### Phase 5 — Verify
+- Re-run `audit_fleet.py`.
+- Diff against the Phase 3 plan: every `RECREATE_IN_TARGET` property must now exist in target account; sources untouched.
+
+### Phase 6 — Re-tag *(mandatory because we recreated)*
+- For every recreated property, find the live site(s) using the OLD `G-XXXXXXXX`.
+- Use `inject_gtag.py --measurement-id <new-G-ID>` to push the new ID.
+- Respect SKILL.md operating rule #5: **never auto-replace UA-XXXXX tags.** Flag, don't replace.
+
+### Phase 7 — Cleanup *(human-gated)*
+- Source accounts now have stranded properties + a paper trail of empty accounts.
+- For each source account: `KEEP / DELETE_PROPERTY / DELETE_ACCOUNT`. Steve approves.
+
+## Standing-rules check (from /best-practices, 2026-04-30)
+
+- ✅ `feedback_suggest_process_md.md` — this file
+- ✅ `feedback_ask_before_doing.md` — Phase 3 + Phase 4 + Phase 7 are explicitly Steve-gated, not auto-executed
+- ✅ `feedback_dns_donotouch.md` (analytics SKILL.md rule #1) — DW-Shopify GA + SDCC carved out as `KEEP_IN_PLACE`
+- ✅ Auth scope — ADC is env-var-only on a single read-only command, not a default change
+- ✅ `feedback_no_cloud_agents.md` — all of this runs locally on Mac Studio 2
+
+## Sharp edges to remember
+
+- **Recreate = new G-IDs.** Every site needs gtag re-deployed. If any site is hardcoded with a measurement ID we can't reach, it's stranded.
+- **Recreate ≠ delete.** Old properties stay in source accounts until Phase 7. During the gap, both old and new emit, which can double-count if any site still has the old gtag while the new one is also injected. Sequence: create new → switch gtag → confirm new is receiving → only then delete old.
+- **GA Admin API rate limits** are tight (10 writes/sec per project). Batch sizes of 5–10 with 90s gaps between batches is the safe rhythm.
+- **Search Console / Ads / BigQuery export links** on any source property break on recreate. Inventory must capture which properties have these.
diff --git a/PLAN.md b/PLAN.md
new file mode 100644
index 0000000..62ed7a1
--- /dev/null
+++ b/PLAN.md
@@ -0,0 +1,117 @@
+# The AI Factory — PLAN.md
+
+**Status:** scaffold (2026-04-30)
+**Owner:** Steve
+**Local path:** `~/Projects/the-ai-factory/`
+**Ports:** orchestrator `9890`, viewer `9891`
+**Pattern reference:** Site Factory (`~/Projects/site-factory/`, ports 9880/9881)
+
+---
+
+## What it is (working hypothesis — confirm before building features)
+
+Orchestrator that turns a single prompt ("build me an X") into a **fully-shipped AI artifact**: a Claude-Code subagent, a `/skill`, a small standalone agent (pm2 service), or a one-shot script. The Factory owns the pipeline, the critic loop, and the registry — humans review the output, not every step.
+
+This is a meta-factory. Site Factory builds sites. Hormuzy builds design. The AI Factory builds the AI things themselves.
+
+> If this hypothesis is wrong, edit this section first — every other choice below flows from it.
+
+## Pipeline (mirrors Site Factory's 11-stage pattern)
+
+1. **Intake** — prompt → structured spec (artifact_type, name, surface, scope)
+2. **Research** — pull adjacent skills/agents from `~/.claude/skills/` + `~/.claude/agents/` so we don't duplicate
+3. **Scaffold** — write files (subagent .md, skill SKILL.md, agent index.js, etc.)
+4. **Wire** — register with pm2 / register in MEMORY.md / add to skill index
+5. **Smoke test** — invoke the artifact end-to-end against a canned input
+6. **Critic (Manager pass)** — second-model review using `claude-api` skill rules: caching, model choice, prompt clarity
+7. **Iterate** — apply critic notes, re-test
+8. **Document** — README.md inside the artifact + one-line entry in this Factory's registry
+9. **Memory write** — emit a memory entry for `MEMORY.md` so future sessions know it exists
+10. **Activate** — flip status to `live`
+11. **Watchdog hook** — if it's a long-running service, register with `process-hawk`
+
+## Stack
+
+- **Runtime:** Node 20 + Express
+- **DB:** PostgreSQL local (`ai_factory` schema in `dw_unified` DB, or its own DB — TBD on first run)
+- **Auth:** none initially (loopback only); add SSO via `dw-sso-integration` skill before exposing externally
+- **LLM:** **NO Anthropic API** (Steve 2026-04-30: "if api costs money.. no"). Hybrid:
+  - **Ollama (local, free)** for mechanical stages — `qwen3:14b` for spec extraction / scaffold-template fill / doc generation. `gemma3:4b` for cheap classification.
+  - **Claude CLI subprocess** (`/Users/stevestudio2/.local/bin/claude -p ...`) for the Manager critic pass only. Goes through Steve's Max sub, not the API → no per-token charges. One call per run keeps quota burn near zero.
+  - Adapters live in `src/llm.js`.
+- **Process manager:** pm2; service name `ai-factory-orchestrator` (`:9890`) and `ai-factory-viewer` (`:9891`)
+- **Watchdog:** add to `com.steve.pd-hawk` style launchd OR `process-hawk` skill once stable
+
+## File layout
+
+```
+the-ai-factory/
+├── PLAN.md                 ← this file
+├── README.md
+├── package.json
+├── .env.example            ← ANTHROPIC_API_KEY, PG_*, ADMIN_TOKEN
+├── server.js               ← Express orchestrator on :9890
+├── viewer.js               ← static viewer on :9891 (matches sf-viewer)
+├── src/
+│   ├── pipeline.js         ← 11-stage runner
+│   ├── stages/             ← one file per stage
+│   ├── critic.js           ← Manager pass
+│   └── registry.js         ← what we've built
+├── sql/
+│   └── 001_init.sql        ← ai_factory.runs / ai_factory.artifacts / ai_factory.events
+├── public/
+│   └── index.html          ← viewer
+└── scripts/
+    └── run.js              ← CLI entry: `node scripts/run.js "build me a foo"`
+```
+
+## Decisions (2026-04-30)
+
+1. **Artifact scope (Q1)** — START NARROW: Claude Code subagents (`~/.claude/agents/*.md`) + `/skills` (`~/.claude/skills/<name>/SKILL.md`) only. pm2 agents come in v2 once the critic loop is proven on the simpler artifacts. *(default — flag if you want broader from day 1)*
+2. **DB home (Q2)** — **STANDALONE DB `ai_factory`** (not inside dw_unified — Steve flagged 2026-04-30: "be careful, create a new stand alone db"). Tables in `public`. Orchestrator boots with a hard safety guard that refuses `PG_DATABASE=dw_unified`.
+3. **Public-facing? (Q3)** — **NO. All backroom internal.** Steve confirmed 2026-04-30. Loopback-only on Mac Studio 2 forever; no Cloudflare, no domain, no SSO, no Kamatera mirror. `127.0.0.1` binding stays.
+4. **Trigger surface (Q4)** — viewer form (`:9891`) + CLI (`scripts/run.js`). A `/ai-factory` slash command is a nice-to-have once the v1 pipeline ships; not blocking. *(default)*
+
+## Standing rules that apply
+
+- Stays local on Mac Studio 2 (no cloud agents).
+- **Loopback only** — `127.0.0.1` binding for both `:9890` and `:9891`. No external exposure ever.
+- **NO Anthropic API** — Steve 2026-04-30. Use local Ollama + Claude CLI subprocess only.
+- Any new long-running service → `pm2 start && pm2 save`.
+- If the Factory generates a *site*, hand off to Site Factory / `dw-site-build`. Don't reimplement.
+- If the Factory generates a *domain* + site, hand off to Onboard Domain Agent.
+
+## Next actions
+
+- [x] Decisions Q1–Q4 locked (2026-04-30)
+- [x] MEMORY.md entry written
+- [x] Standalone DB `ai_factory` created + schema applied
+- [x] Orchestrator + viewer boot clean on `:9890` / `:9891`
+- [x] No-API decision locked (hybrid Ollama + Claude CLI)
+- [x] Stage 1 (intake) WIRED — qwen3:14b extracts spec as JSON
+- [x] Stage 3 (scaffold) WIRED — qwen3:14b template-fills subagent .md OR skill SKILL.md, writes to `output/run-<id>/...`, frontmatter validated
+- [x] Stage 6 (critic) WIRED — Claude CLI subprocess returns `{approved, score, issues, fix_instructions}` JSON
+- [x] Stage 7 (iterate) WIRED — qwen3:14b applies fix_instructions, overwrites file (single pass)
+- [x] Verified end-to-end on `shopify-cost-breakdown` skill prompt: 145s total, critic scored v1 at 62 → iterate applied 10 fixes → final SKILL.md is shippable. See `output/run-4/`.
+- [x] **Re-critic loop WIRED** — after iterate we re-score v2; the run's final status reflects the revised review, not the v1 review. Verified on run-5 (`sql-tldr-summarizer`): v1=62/false → iterate(8 fixes) → v2=82/**true** → status=`awaiting_activation_v2`.
+- [x] **Activate-with-approval WIRED** — `POST /runs/:id/activate` is the only path that touches `~/.claude/agents/` or `~/.claude/skills/`. Gates: (a) run status must start with `awaiting_activation` unless `?force=true`, (b) won't overwrite an existing target unless `?overwrite=true`, (c) refuses to copy from outside the sandbox dir. Viewer surfaces an Activate button on eligible runs.
+- [x] **Stage 9 (memory_write) WIRED** — runs as part of the activate handler (best-effort; activation succeeds even if memory append fails). Appends one line to `MEMORY.md` of the form `- AI Factory shipped: ``<name>`` (<type>) — <scope> [run #N, YYYY-MM-DD, lives in ~/.claude/...]`. Skips if the same artifact name is already in the index. Verified on run-5: line appears, file lives at `~/.claude/skills/sql-tldr-summarizer/SKILL.md`, run status=activated.
+- [x] **Stage 2 (research) WIRED** — pure-local bigram-Jaccard scan of `~/.claude/agents/` and `~/.claude/skills/` for similar names. Logs an `exact-name` warning if a conflict exists. Non-blocking; runs in parallel with scaffold (Promise.all).
+- [x] **Stage 5 (smoke_test) WIRED** — pure-local structural validation (frontmatter, name match, body length, no unrendered placeholders, tools/model for subagents). Runs after intake's first-pass scaffold; re-runs on v2 if iterate fired.
+- [x] **Stage 8 (document) WIRED** — for skills only, drops a `README.md` next to `SKILL.md` in the artifact dir. Subagents skipped (single-file, self-documenting). Runs in parallel with critic.
+- [x] **Stage 4 (wire) explicit no-op** — for v1 subagent/skill artifacts there's no external registration step (the harness auto-discovers). Logged as no-op for clarity.
+- [x] **Stage 11 (watchdog_hook) explicit no-op** — only relevant for v2 service-type artifacts.
+- [x] **Pipeline parallelization** — research‖scaffold and document‖critic run as `Promise.all`. Should shave ~10–30s off the ~145s baseline (qwen3:14b scaffold ≈30s parallel with research's instant local read; document's instant write parallel with critic's ~25s Claude CLI call).
+- [x] **Viewer drill-down** — clicking a run row expands it inline to show the full event timeline (stage, name, level, message, payload as collapsible JSON). No separate page.
+- [ ] Confirm working hypothesis (one-line edit at top) — Steve
+- [ ] Stage 8 (document) — generate a README inside the artifact dir (skills only)
+- [ ] Stage 11 (watchdog_hook) — for service-type artifacts only (v2)
+- [ ] Viewer drill-down — per-run event timeline (currently only lists runs)
+
+## Out of scope (per Q3 = backroom internal)
+
+- ~~Domain registration~~
+- ~~SSO via dw-sso-integration~~
+- ~~Cloudflare zone~~
+- ~~Kamatera mirror~~
+- ~~theaifactory.com~~
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d071b04
--- /dev/null
+++ b/README.md
@@ -0,0 +1,28 @@
+# The AI Factory
+
+Meta-orchestrator that turns one prompt into a shipped AI artifact (subagent, skill, agent, or script).
+
+- **Path:** `~/Projects/the-ai-factory/`
+- **Orchestrator:** `:9890`
+- **Viewer:** `:9891`
+- **Plan:** see `PLAN.md` (read first)
+- **Pattern:** mirrors Site Factory (`~/Projects/site-factory/`)
+
+## Quick start
+
+```bash
+cd ~/Projects/the-ai-factory
+cp .env.example .env  # fill in ANTHROPIC_API_KEY, PG_*
+npm install
+npm run start         # boots orchestrator (:9890) + viewer (:9891)
+```
+
+CLI:
+
+```bash
+node scripts/run.js "build me a subagent that watches the freezer for expired vendors"
+```
+
+## Status
+
+Scaffold only. See `PLAN.md` → "Open questions" before adding pipeline logic.
diff --git a/output/clickthrough/01-table.png b/output/clickthrough/01-table.png
new file mode 100644
index 0000000..8d42b42
Binary files /dev/null and b/output/clickthrough/01-table.png differ
diff --git a/output/clickthrough/02-events-expanded.png b/output/clickthrough/02-events-expanded.png
new file mode 100644
index 0000000..da4a2e8
Binary files /dev/null and b/output/clickthrough/02-events-expanded.png differ
diff --git a/output/clickthrough/summary.json b/output/clickthrough/summary.json
new file mode 100644
index 0000000..fa88ea5
--- /dev/null
+++ b/output/clickthrough/summary.json
@@ -0,0 +1,10 @@
+{
+  "viewer_status": "reachable",
+  "row_count": 6,
+  "activate_buttons": 1,
+  "console_messages": 0,
+  "screenshots": [
+    "01-table.png",
+    "02-events-expanded.png"
+  ]
+}
\ No newline at end of file
diff --git a/output/run-4/shopify-cost-breakdown/SKILL.md b/output/run-4/shopify-cost-breakdown/SKILL.md
new file mode 100644
index 0000000..1317737
--- /dev/null
+++ b/output/run-4/shopify-cost-breakdown/SKILL.md
@@ -0,0 +1,65 @@
+---
+name: shopify-cost-breakdown
+description: Generates cost, MAP (Minimum Advertised Price), retail, and margin breakdown for Shopify product variants when given a product handle and user-supplied cost data. Auto-triggers on "cost breakdown for handle" or similar phrases. Requires handle + unit_cost; no live Shopify API integration.  
+---
+
+# Shopify Cost Breakdown
+
+## Trigger  
+- "cost breakdown for handle"  
+- "variant cost breakdown"  
+- "shopify product cost"  
+- "price breakdown for handle"  
+- "cost analysis for handle"  
+
+## When to use  
+When a user provides a Shopify product handle (e.g., `summer-dress`) and explicitly supplies unit_cost (e.g., $15.00) to calculate cost, MAP, retail, and margin.  
+
+## What it does  
+- Calculates total cost per variant using user-provided unit_cost  
+- Computes MAP using user-defined minimum advertised price or default formula (MAP = cost × 1.5)  
+- Estimates retail price using user-specified markup percentage or default formula (retail = cost / (1 - 0.3))  
+- Determines gross margin and net profit per unit based on inputs  
+- Outputs structured tables with currency formatting  
+
+## How to invoke  
+1. Provide the Shopify product handle (e.g., `summer-dress`)  
+2. Supply **required** cost data: unit_cost (e.g., $15.00)  
+3. Optionally specify MAP (e.g., $25.00), markup percentage (e.g., 30%), or retail price  
+4. Choose output format: table, JSON, or markdown  
+
+## Input contract  
+**Required fields**:  
+- `handle`: Shopify product handle (e.g., `summer-dress`)  
+- `unit_cost`: Base cost per unit (e.g., $15.00)  
+
+**Optional fields**:  
+- `map`: Minimum Advertised Price (e.g., $25.00)  
+- `markup_percent`: Retail markup percentage (e.g., 30%)  
+- `retail_price`: Final retail price (e.g., $30.00)  
+- `currency`: ISO currency code (e.g., USD)  
+
+## Example  
+**Input**:  
+```plaintext
+handle: summer-dress  
+unit_cost: 15.00  
+map: 25.00  
+currency: USD  
+```  
+
+**Output**:  
+| Metric       | Value    |  
+|--------------|----------|  
+| Unit Cost    | $15.00   |  
+| MAP          | $25.00   |  
+| Retail Price | $30.00   |  
+| Gross Margin | $15.00   |  
+| Margin %     | 50.00%   |  
+
+## Out of scope  
+- Fetching live Shopify API data  
+- Processing multiple handles simultaneously  
+- Calculating margins without explicit cost data  
+
+---
\ No newline at end of file
diff --git a/output/run-5/sql-tldr-summarizer/SKILL.md b/output/run-5/sql-tldr-summarizer/SKILL.md
new file mode 100644
index 0000000..8ac6ad2
--- /dev/null
+++ b/output/run-5/sql-tldr-summarizer/SKILL.md
@@ -0,0 +1,40 @@
+---
+name: sql-tldr-summarizer
+description: Generates a 3-bullet executive summary from SQL query results when triggered by pasted SQL output and a "TLDR" request. Triggers include "TLDR", "summarize this", "executive summary", or "3 bullet points".
+
+---
+
+# SQL TLDR Summarizer
+
+## When to use
+- Paste SQL output and request a summary
+- Ask for "executive summary" with SQL data
+- Demand "3 bullet points" from SQL results
+
+## How to summarize
+1. Parse the SQL result table or output
+2. Identify: 
+   - (1) Headline number/finding (e.g., "Total sales: $1.2M")
+   - (2) Notable trend or comparison (e.g., "Q2 revenue up 18% YoY")
+   - (3) Outlier or caveat (e.g., "30% of regions below target")
+3. Format as 3 bullets, each ≤20 words, leading with the metric
+
+## Example
+**Input:**
+```
+| Region | Sales  |
+|--------|--------|
+| North  | 450000 |
+| South  | 750000 |
+| East   | 250000 |
+```
+
+**Output:**
+- Total sales: $1.4M across regions
+- South region leads with 750k sales (54% of total)
+- East region underperforms with 250k sales (18% of total)
+
+## Out of scope
+- Execute SQL queries
+- Modify original data
+- Handle non-SQL content
\ No newline at end of file
diff --git a/output/run-6/pm2-crash-watcher.md b/output/run-6/pm2-crash-watcher.md
new file mode 100644
index 0000000..573b56e
--- /dev/null
+++ b/output/run-6/pm2-crash-watcher.md
@@ -0,0 +1,48 @@
+---
+name: pm2-crash-watcher
+description: Monitors PM2-managed processes for crashes in the last hour, reporting metadata like crash time, exit code, and memory usage. Use this subagent when investigating recent process failures or auditing system stability. 
+tools: Bash
+model: sonnet
+---
+
+## Your job  
+When invoked, analyze PM2 processes to identify crashes in the last hour, extracting structured metadata and relevant log snippets.
+
+## What to check / do  
+1. Use `pm2 jlist` to get structured process metadata (pm2_env.exit_code, pm2_env.restart_time, monit.memory, pm_uptime)  
+2. Filter processes with restart_time within the last 60 minutes  
+3. For each candidate process, run `pm2 logs <name> --err --lines 10 --nostream` to capture recent error logs  
+4. Correlate log timestamps with process metadata to confirm crashes  
+5. Format results as a human-readable list showing:  
+   - Process name  
+   - Time since crash (e.g., "12m ago")  
+   - Exit code  
+   - Memory usage  
+   - Last error line from logs  
+
+## Output format  
+Default: Human-readable summary (one line per crashed agent)  
+Example:  
+```text
+[ERROR] agent-worker (12m ago) - Exit code 1 - 512MB used - "UncaughtException: TypeError..."
+```  
+JSON available on request:  
+```json
+{
+  "crashed_agents": [
+    {
+      "name": "agent-worker",
+      "crash_time": "2024-03-20T14:30:00Z",
+      "exit_code": 1,
+      "memory_used": "512MB",
+      "log_snippet": "UncaughtException: TypeError..."
+    }
+  ]
+}
+```
+
+## Out of scope  
+- Restarting processes  
+- Historical analysis beyond 1 hour  
+- Modifying PM2 configurations  
+- Continuous log tailing (agent is invoked per request)
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
new file mode 100644
index 0000000..07fb464
--- /dev/null
+++ b/package-lock.json
@@ -0,0 +1,1293 @@
+{
+  "name": "the-ai-factory",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "the-ai-factory",
+      "version": "0.1.0",
+      "dependencies": {
+        "@anthropic-ai/sdk": "^0.32.0",
+        "dotenv": "^16.4.5",
+        "express": "^4.19.2",
+        "pg": "^8.12.0",
+        "playwright": "^1.59.1"
+      }
+    },
+    "node_modules/@anthropic-ai/sdk": {
+      "version": "0.32.1",
+      "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.32.1.tgz",
+      "integrity": "sha512-U9JwTrDvdQ9iWuABVsMLj8nJVwAyQz6QXvgLsVhryhCEPkLsbcP/MXxm+jYcAwLoV8ESbaTTjnD4kuAFa+Hyjg==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "^18.11.18",
+        "@types/node-fetch": "^2.6.4",
+        "abort-controller": "^3.0.0",
+        "agentkeepalive": "^4.2.1",
+        "form-data-encoder": "1.7.2",
+        "formdata-node": "^4.3.2",
+        "node-fetch": "^2.6.7"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "18.19.130",
+      "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz",
+      "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==",
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~5.26.4"
+      }
+    },
+    "node_modules/@types/node-fetch": {
+      "version": "2.6.13",
+      "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz",
+      "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/node": "*",
+        "form-data": "^4.0.4"
+      }
+    },
+    "node_modules/abort-controller": {
+      "version": "3.0.0",
+      "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz",
+      "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==",
+      "license": "MIT",
+      "dependencies": {
+        "event-target-shim": "^5.0.0"
+      },
+      "engines": {
+        "node": ">=6.5"
+      }
+    },
+    "node_modules/accepts": {
+      "version": "1.3.8",
+      "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz",
+      "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-types": "~2.1.34",
+        "negotiator": "0.6.3"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/agentkeepalive": {
+      "version": "4.6.0",
+      "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz",
+      "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==",
+      "license": "MIT",
+      "dependencies": {
+        "humanize-ms": "^1.2.1"
+      },
+      "engines": {
+        "node": ">= 8.0.0"
+      }
+    },
+    "node_modules/array-flatten": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz",
+      "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==",
+      "license": "MIT"
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/body-parser": {
+      "version": "1.20.5",
+      "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.5.tgz",
+      "integrity": "sha512-3grm+/2tUOvu2cjJkvsIxrv/wVpfXQW4PsQHYm7yk4vfpu7Ekl6nEsYBoJUL6qDwZUx8wUhQ8tR2qz+ad9c9OA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "content-type": "~1.0.5",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "~1.2.0",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "on-finished": "~2.4.1",
+        "qs": "~6.15.1",
+        "raw-body": "~2.5.3",
+        "type-is": "~1.6.18",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/body-parser/node_modules/qs": {
+      "version": "6.15.1",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.1.tgz",
+      "integrity": "sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/bytes": {
+      "version": "3.1.2",
+      "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
+      "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/call-bound": {
+      "version": "1.0.4",
+      "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
+      "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "get-intrinsic": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/content-disposition": {
+      "version": "0.5.4",
+      "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz",
+      "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==",
+      "license": "MIT",
+      "dependencies": {
+        "safe-buffer": "5.2.1"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/content-type": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz",
+      "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie": {
+      "version": "0.7.2",
+      "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz",
+      "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/cookie-signature": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz",
+      "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==",
+      "license": "MIT"
+    },
+    "node_modules/debug": {
+      "version": "2.6.9",
+      "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+      "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "2.0.0"
+      }
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/depd": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
+      "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/destroy": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz",
+      "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8",
+        "npm": "1.2.8000 || >= 1.4.16"
+      }
+    },
+    "node_modules/dotenv": {
+      "version": "16.6.1",
+      "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
+      "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://dotenvx.com"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/ee-first": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz",
+      "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==",
+      "license": "MIT"
+    },
+    "node_modules/encodeurl": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz",
+      "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/escape-html": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz",
+      "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==",
+      "license": "MIT"
+    },
+    "node_modules/etag": {
+      "version": "1.8.1",
+      "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz",
+      "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/event-target-shim": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz",
+      "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6"
+      }
+    },
+    "node_modules/express": {
+      "version": "4.22.1",
+      "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz",
+      "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==",
+      "license": "MIT",
+      "dependencies": {
+        "accepts": "~1.3.8",
+        "array-flatten": "1.1.1",
+        "body-parser": "~1.20.3",
+        "content-disposition": "~0.5.4",
+        "content-type": "~1.0.4",
+        "cookie": "~0.7.1",
+        "cookie-signature": "~1.0.6",
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "finalhandler": "~1.3.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.0",
+        "merge-descriptors": "1.0.3",
+        "methods": "~1.1.2",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "path-to-regexp": "~0.1.12",
+        "proxy-addr": "~2.0.7",
+        "qs": "~6.14.0",
+        "range-parser": "~1.2.1",
+        "safe-buffer": "5.2.1",
+        "send": "~0.19.0",
+        "serve-static": "~1.16.2",
+        "setprototypeof": "1.2.0",
+        "statuses": "~2.0.1",
+        "type-is": "~1.6.18",
+        "utils-merge": "1.0.1",
+        "vary": "~1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.10.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/finalhandler": {
+      "version": "1.3.2",
+      "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz",
+      "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "on-finished": "~2.4.1",
+        "parseurl": "~1.3.3",
+        "statuses": "~2.0.2",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/form-data-encoder": {
+      "version": "1.7.2",
+      "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz",
+      "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==",
+      "license": "MIT"
+    },
+    "node_modules/formdata-node": {
+      "version": "4.4.1",
+      "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz",
+      "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==",
+      "license": "MIT",
+      "dependencies": {
+        "node-domexception": "1.0.0",
+        "web-streams-polyfill": "4.0.0-beta.3"
+      },
+      "engines": {
+        "node": ">= 12.20"
+      }
+    },
+    "node_modules/forwarded": {
+      "version": "0.2.0",
+      "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz",
+      "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fresh": {
+      "version": "0.5.2",
+      "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz",
+      "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
+      "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.3",
+      "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz",
+      "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/http-errors": {
+      "version": "2.0.1",
+      "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz",
+      "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==",
+      "license": "MIT",
+      "dependencies": {
+        "depd": "~2.0.0",
+        "inherits": "~2.0.4",
+        "setprototypeof": "~1.2.0",
+        "statuses": "~2.0.2",
+        "toidentifier": "~1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/express"
+      }
+    },
+    "node_modules/humanize-ms": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz",
+      "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==",
+      "license": "MIT",
+      "dependencies": {
+        "ms": "^2.0.0"
+      }
+    },
+    "node_modules/iconv-lite": {
+      "version": "0.4.24",
+      "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz",
+      "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==",
+      "license": "MIT",
+      "dependencies": {
+        "safer-buffer": ">= 2.1.2 < 3"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/inherits": {
+      "version": "2.0.4",
+      "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
+      "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
+      "license": "ISC"
+    },
+    "node_modules/ipaddr.js": {
+      "version": "1.9.1",
+      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
+      "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/media-typer": {
+      "version": "0.3.0",
+      "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-0.3.0.tgz",
+      "integrity": "sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/merge-descriptors": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz",
+      "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/sindresorhus"
+      }
+    },
+    "node_modules/methods": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz",
+      "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime": {
+      "version": "1.6.0",
+      "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz",
+      "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==",
+      "license": "MIT",
+      "bin": {
+        "mime": "cli.js"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/ms": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+      "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+      "license": "MIT"
+    },
+    "node_modules/negotiator": {
+      "version": "0.6.3",
+      "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz",
+      "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/node-domexception": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz",
+      "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==",
+      "deprecated": "Use your platform's native DOMException instead",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/jimmywarting"
+        },
+        {
+          "type": "github",
+          "url": "https://paypal.me/jimmywarting"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=10.5.0"
+      }
+    },
+    "node_modules/node-fetch": {
+      "version": "2.7.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
+      "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
+      "license": "MIT",
+      "dependencies": {
+        "whatwg-url": "^5.0.0"
+      },
+      "engines": {
+        "node": "4.x || >=6.0.0"
+      },
+      "peerDependencies": {
+        "encoding": "^0.1.0"
+      },
+      "peerDependenciesMeta": {
+        "encoding": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/object-inspect": {
+      "version": "1.13.4",
+      "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz",
+      "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/on-finished": {
+      "version": "2.4.1",
+      "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz",
+      "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==",
+      "license": "MIT",
+      "dependencies": {
+        "ee-first": "1.1.1"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/parseurl": {
+      "version": "1.3.3",
+      "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz",
+      "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/path-to-regexp": {
+      "version": "0.1.13",
+      "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz",
+      "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==",
+      "license": "MIT"
+    },
+    "node_modules/pg": {
+      "version": "8.20.0",
+      "resolved": "https://registry.npmjs.org/pg/-/pg-8.20.0.tgz",
+      "integrity": "sha512-ldhMxz2r8fl/6QkXnBD3CR9/xg694oT6DZQ2s6c/RI28OjtSOpxnPrUCGOBJ46RCUxcWdx3p6kw/xnDHjKvaRA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-connection-string": "^2.12.0",
+        "pg-pool": "^3.13.0",
+        "pg-protocol": "^1.13.0",
+        "pg-types": "2.2.0",
+        "pgpass": "1.0.5"
+      },
+      "engines": {
+        "node": ">= 16.0.0"
+      },
+      "optionalDependencies": {
+        "pg-cloudflare": "^1.3.0"
+      },
+      "peerDependencies": {
+        "pg-native": ">=3.0.1"
+      },
+      "peerDependenciesMeta": {
+        "pg-native": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/pg-cloudflare": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmjs.org/pg-cloudflare/-/pg-cloudflare-1.3.0.tgz",
+      "integrity": "sha512-6lswVVSztmHiRtD6I8hw4qP/nDm1EJbKMRhf3HCYaqud7frGysPv7FYJ5noZQdhQtN2xJnimfMtvQq21pdbzyQ==",
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/pg-connection-string": {
+      "version": "2.12.0",
+      "resolved": "https://registry.npmjs.org/pg-connection-string/-/pg-connection-string-2.12.0.tgz",
+      "integrity": "sha512-U7qg+bpswf3Cs5xLzRqbXbQl85ng0mfSV/J0nnA31MCLgvEaAo7CIhmeyrmJpOr7o+zm0rXK+hNnT5l9RHkCkQ==",
+      "license": "MIT"
+    },
+    "node_modules/pg-int8": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/pg-int8/-/pg-int8-1.0.1.tgz",
+      "integrity": "sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==",
+      "license": "ISC",
+      "engines": {
+        "node": ">=4.0.0"
+      }
+    },
+    "node_modules/pg-pool": {
+      "version": "3.13.0",
+      "resolved": "https://registry.npmjs.org/pg-pool/-/pg-pool-3.13.0.tgz",
+      "integrity": "sha512-gB+R+Xud1gLFuRD/QgOIgGOBE2KCQPaPwkzBBGC9oG69pHTkhQeIuejVIk3/cnDyX39av2AxomQiyPT13WKHQA==",
+      "license": "MIT",
+      "peerDependencies": {
+        "pg": ">=8.0"
+      }
+    },
+    "node_modules/pg-protocol": {
+      "version": "1.13.0",
+      "resolved": "https://registry.npmjs.org/pg-protocol/-/pg-protocol-1.13.0.tgz",
+      "integrity": "sha512-zzdvXfS6v89r6v7OcFCHfHlyG/wvry1ALxZo4LqgUoy7W9xhBDMaqOuMiF3qEV45VqsN6rdlcehHrfDtlCPc8w==",
+      "license": "MIT"
+    },
+    "node_modules/pg-types": {
+      "version": "2.2.0",
+      "resolved": "https://registry.npmjs.org/pg-types/-/pg-types-2.2.0.tgz",
+      "integrity": "sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==",
+      "license": "MIT",
+      "dependencies": {
+        "pg-int8": "1.0.1",
+        "postgres-array": "~2.0.0",
+        "postgres-bytea": "~1.0.0",
+        "postgres-date": "~1.0.4",
+        "postgres-interval": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/pgpass": {
+      "version": "1.0.5",
+      "resolved": "https://registry.npmjs.org/pgpass/-/pgpass-1.0.5.tgz",
+      "integrity": "sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==",
+      "license": "MIT",
+      "dependencies": {
+        "split2": "^4.1.0"
+      }
+    },
+    "node_modules/playwright": {
+      "version": "1.59.1",
+      "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.59.1.tgz",
+      "integrity": "sha512-C8oWjPR3F81yljW9o5OxcWzfh6avkVwDD2VYdwIGqTkl+OGFISgypqzfu7dOe4QNLL2aqcWBmI3PMtLIK233lw==",
+      "license": "Apache-2.0",
+      "dependencies": {
+        "playwright-core": "1.59.1"
+      },
+      "bin": {
+        "playwright": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      },
+      "optionalDependencies": {
+        "fsevents": "2.3.2"
+      }
+    },
+    "node_modules/playwright-core": {
+      "version": "1.59.1",
+      "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.59.1.tgz",
+      "integrity": "sha512-HBV/RJg81z5BiiZ9yPzIiClYV/QMsDCKUyogwH9p3MCP6IYjUFu/MActgYAvK0oWyV9NlwM3GLBjADyWgydVyg==",
+      "license": "Apache-2.0",
+      "bin": {
+        "playwright-core": "cli.js"
+      },
+      "engines": {
+        "node": ">=18"
+      }
+    },
+    "node_modules/postgres-array": {
+      "version": "2.0.0",
+      "resolved": "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz",
+      "integrity": "sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=4"
+      }
+    },
+    "node_modules/postgres-bytea": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/postgres-bytea/-/postgres-bytea-1.0.1.tgz",
+      "integrity": "sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-date": {
+      "version": "1.0.7",
+      "resolved": "https://registry.npmjs.org/postgres-date/-/postgres-date-1.0.7.tgz",
+      "integrity": "sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/postgres-interval": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/postgres-interval/-/postgres-interval-1.2.0.tgz",
+      "integrity": "sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==",
+      "license": "MIT",
+      "dependencies": {
+        "xtend": "^4.0.0"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/proxy-addr": {
+      "version": "2.0.7",
+      "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz",
+      "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==",
+      "license": "MIT",
+      "dependencies": {
+        "forwarded": "0.2.0",
+        "ipaddr.js": "1.9.1"
+      },
+      "engines": {
+        "node": ">= 0.10"
+      }
+    },
+    "node_modules/qs": {
+      "version": "6.14.2",
+      "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.2.tgz",
+      "integrity": "sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==",
+      "license": "BSD-3-Clause",
+      "dependencies": {
+        "side-channel": "^1.1.0"
+      },
+      "engines": {
+        "node": ">=0.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/range-parser": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz",
+      "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/raw-body": {
+      "version": "2.5.3",
+      "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz",
+      "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "bytes": "~3.1.2",
+        "http-errors": "~2.0.1",
+        "iconv-lite": "~0.4.24",
+        "unpipe": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/safe-buffer": {
+      "version": "5.2.1",
+      "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+      "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/feross"
+        },
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/feross"
+        },
+        {
+          "type": "consulting",
+          "url": "https://feross.org/support"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/safer-buffer": {
+      "version": "2.1.2",
+      "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
+      "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==",
+      "license": "MIT"
+    },
+    "node_modules/send": {
+      "version": "0.19.2",
+      "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz",
+      "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==",
+      "license": "MIT",
+      "dependencies": {
+        "debug": "2.6.9",
+        "depd": "2.0.0",
+        "destroy": "1.2.0",
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "etag": "~1.8.1",
+        "fresh": "~0.5.2",
+        "http-errors": "~2.0.1",
+        "mime": "1.6.0",
+        "ms": "2.1.3",
+        "on-finished": "~2.4.1",
+        "range-parser": "~1.2.1",
+        "statuses": "~2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/send/node_modules/ms": {
+      "version": "2.1.3",
+      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+      "license": "MIT"
+    },
+    "node_modules/serve-static": {
+      "version": "1.16.3",
+      "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz",
+      "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==",
+      "license": "MIT",
+      "dependencies": {
+        "encodeurl": "~2.0.0",
+        "escape-html": "~1.0.3",
+        "parseurl": "~1.3.3",
+        "send": "~0.19.1"
+      },
+      "engines": {
+        "node": ">= 0.8.0"
+      }
+    },
+    "node_modules/setprototypeof": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
+      "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==",
+      "license": "ISC"
+    },
+    "node_modules/side-channel": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz",
+      "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.3",
+        "side-channel-list": "^1.0.0",
+        "side-channel-map": "^1.0.1",
+        "side-channel-weakmap": "^1.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-list": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz",
+      "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "object-inspect": "^1.13.4"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-map": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz",
+      "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/side-channel-weakmap": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz",
+      "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bound": "^1.0.2",
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.5",
+        "object-inspect": "^1.13.3",
+        "side-channel-map": "^1.0.1"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/split2": {
+      "version": "4.2.0",
+      "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
+      "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
+      "license": "ISC",
+      "engines": {
+        "node": ">= 10.x"
+      }
+    },
+    "node_modules/statuses": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
+      "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/toidentifier": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz",
+      "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.6"
+      }
+    },
+    "node_modules/tr46": {
+      "version": "0.0.3",
+      "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
+      "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
+      "license": "MIT"
+    },
+    "node_modules/type-is": {
+      "version": "1.6.18",
+      "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.18.tgz",
+      "integrity": "sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==",
+      "license": "MIT",
+      "dependencies": {
+        "media-typer": "0.3.0",
+        "mime-types": "~2.1.24"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "5.26.5",
+      "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz",
+      "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==",
+      "license": "MIT"
+    },
+    "node_modules/unpipe": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
+      "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/utils-merge": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz",
+      "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4.0"
+      }
+    },
+    "node_modules/vary": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz",
+      "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/web-streams-polyfill": {
+      "version": "4.0.0-beta.3",
+      "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz",
+      "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14"
+      }
+    },
+    "node_modules/webidl-conversions": {
+      "version": "3.0.1",
+      "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
+      "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
+      "license": "BSD-2-Clause"
+    },
+    "node_modules/whatwg-url": {
+      "version": "5.0.0",
+      "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
+      "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
+      "license": "MIT",
+      "dependencies": {
+        "tr46": "~0.0.3",
+        "webidl-conversions": "^3.0.0"
+      }
+    },
+    "node_modules/xtend": {
+      "version": "4.0.2",
+      "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
+      "integrity": "sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4"
+      }
+    }
+  }
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..4c96e53
--- /dev/null
+++ b/package.json
@@ -0,0 +1,21 @@
+{
+  "name": "the-ai-factory",
+  "version": "0.1.0",
+  "private": true,
+  "description": "Meta-orchestrator: prompt -> shipped AI artifact (subagent, skill, agent, script).",
+  "main": "server.js",
+  "scripts": {
+    "start": "node server.js",
+    "viewer": "node viewer.js",
+    "run": "node scripts/run.js",
+    "pm2:start": "pm2 start server.js --name ai-factory-orchestrator && pm2 start viewer.js --name ai-factory-viewer && pm2 save",
+    "pm2:logs": "pm2 logs ai-factory-orchestrator"
+  },
+  "dependencies": {
+    "@anthropic-ai/sdk": "^0.32.0",
+    "dotenv": "^16.4.5",
+    "express": "^4.19.2",
+    "pg": "^8.12.0",
+    "playwright": "^1.59.1"
+  }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..8c7d3df
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,150 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8" />
+<meta name="viewport" content="width=device-width,initial-scale=1" />
+<title>The AI Factory</title>
+<style>
+  :root {
+    --bg: #0e0e10;
+    --panel: #16161a;
+    --line: #2a2a30;
+    --text: #e8e8ea;
+    --muted: #8a8a92;
+    --accent: #d4af37;
+  }
+  html, body { margin: 0; background: var(--bg); color: var(--text); font: 14px/1.45 -apple-system, BlinkMacSystemFont, "SF Pro Text", system-ui, sans-serif; }
+  header { padding: 24px 32px; border-bottom: 1px solid var(--line); display: flex; align-items: baseline; gap: 16px; }
+  header h1 { margin: 0; font-size: 20px; letter-spacing: 0.02em; }
+  header .sub { color: var(--muted); font-size: 12px; }
+  main { padding: 24px 32px; max-width: 1100px; }
+  form { display: flex; gap: 8px; margin-bottom: 24px; }
+  input[type=text] { flex: 1; background: var(--panel); border: 1px solid var(--line); color: var(--text); padding: 10px 12px; border-radius: 6px; font: inherit; }
+  button { background: var(--accent); color: #1b1b1b; border: 0; padding: 10px 16px; border-radius: 6px; font: inherit; font-weight: 600; cursor: pointer; }
+  button:disabled { opacity: 0.4; cursor: not-allowed; }
+  .activate-btn { padding: 4px 10px; font-size: 12px; }
+  tr.run-row { cursor: pointer; }
+  tr.run-row:hover { background: #1a1a1f; }
+  tr.events-row td { background: #0a0a0c; padding: 0; border-bottom: 1px solid var(--line); }
+  .events-pane { padding: 12px 16px; }
+  .events-pane h4 { margin: 0 0 8px; font-size: 11px; color: var(--muted); letter-spacing: 0.06em; text-transform: uppercase; }
+  .event { display: grid; grid-template-columns: 60px 110px 50px 1fr; gap: 12px; padding: 4px 0; border-bottom: 1px dotted #222; font-size: 12px; }
+  .event:last-child { border-bottom: 0; }
+  .event .stg { color: var(--muted); }
+  .event .nm  { color: var(--accent); }
+  .event .lv  { font-size: 10px; text-transform: uppercase; }
+  .event .lv.warn  { color: #f4b400; }
+  .event .lv.error { color: #ff6b6b; }
+  .event .lv.info  { color: #5fb878; }
+  .event .ms  { color: var(--text); }
+  .event details { margin-top: 4px; }
+  .event details summary { color: var(--muted); cursor: pointer; font-size: 10px; }
+  .event pre { background: #050507; color: #d0d0d4; padding: 8px; margin: 4px 0 0; border-radius: 4px; font-size: 11px; overflow-x: auto; max-height: 200px; }
+  table { width: 100%; border-collapse: collapse; }
+  th, td { padding: 10px 12px; text-align: left; border-bottom: 1px solid var(--line); font-size: 13px; }
+  th { color: var(--muted); font-weight: 500; font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; }
+  td.muted { color: var(--muted); }
+  td.status { font-weight: 600; }
+  .empty { color: var(--muted); padding: 24px 0; text-align: center; }
+  .err { color: #ff6b6b; font-size: 12px; margin-top: 8px; }
+</style>
+</head>
+<body>
+<header>
+  <h1>The AI Factory</h1>
+  <span class="sub">prompt → shipped AI artifact · :9890 orchestrator · :9891 viewer</span>
+</header>
+<main>
+  <form id="newRun">
+    <input type="text" id="prompt" placeholder="Describe the AI artifact to build (subagent, skill, agent, script)…" />
+    <button type="submit">Build</button>
+  </form>
+  <div id="err" class="err"></div>
+  <table>
+    <thead><tr><th>#</th><th>Prompt</th><th>Type</th><th>Name</th><th>Stage</th><th>Status</th><th>Created</th><th></th></tr></thead>
+    <tbody id="runs"><tr><td class="empty" colspan="8">No runs yet.</td></tr></tbody>
+  </table>
+</main>
+<script>
+const ORCH = 'http://127.0.0.1:9890';
+async function loadRuns() {
+  const tbody = document.getElementById('runs');
+  const errEl = document.getElementById('err');
+  try {
+    const res = await fetch(ORCH + '/runs');
+    const data = await res.json();
+    if (data.error) { errEl.textContent = data.error + (data.hint ? ' — ' + data.hint : ''); return; }
+    errEl.textContent = '';
+    if (!data.runs || !data.runs.length) {
+      tbody.innerHTML = '<tr><td class="empty" colspan="8">No runs yet.</td></tr>';
+      return;
+    }
+    tbody.innerHTML = data.runs.map(r => {
+      const canActivate = String(r.status).startsWith('awaiting_activation');
+      const isActivated = r.status === 'activated';
+      const action = canActivate
+        ? `<button data-id="${r.id}" class="activate-btn">Activate</button>`
+        : (isActivated ? '<span class="muted">activated</span>' : '');
+      return `
+      <tr class="run-row" data-id="${r.id}">
+        <td class="muted">${r.id}</td>
+        <td>${escapeHtml(r.prompt).slice(0, 100)}</td>
+        <td class="muted">${r.artifact_type || '—'}</td>
+        <td class="muted">${escapeHtml(r.artifact_name || '—')}</td>
+        <td class="muted">${r.current_stage}</td>
+        <td class="status">${r.status}</td>
+        <td class="muted">${new Date(r.created_at).toLocaleString()}</td>
+        <td>${action}</td>
+      </tr>
+      <tr class="events-row" id="events-${r.id}" style="display:none;"><td colspan="8"><div class="events-pane" id="events-pane-${r.id}">loading…</div></td></tr>`;
+    }).join('');
+    document.querySelectorAll('.activate-btn').forEach(btn => {
+      btn.addEventListener('click', async (e) => {
+        e.stopPropagation();
+        const id = btn.getAttribute('data-id');
+        if (!confirm(`Activate run ${id}? This copies the file to ~/.claude/.`)) return;
+        btn.disabled = true;
+        const res = await fetch(`${ORCH}/runs/${id}/activate`, { method: 'POST' });
+        const data = await res.json();
+        if (data.error) alert(`Activate failed: ${data.error}\n\n${data.hint || ''}`);
+        else alert(`Activated → ${data.dest}`);
+        loadRuns();
+      });
+    });
+    document.querySelectorAll('.run-row').forEach(row => {
+      row.addEventListener('click', async () => {
+        const id = row.getAttribute('data-id');
+        const evRow = document.getElementById(`events-${id}`);
+        const evPane = document.getElementById(`events-pane-${id}`);
+        if (evRow.style.display === 'none') {
+          evRow.style.display = '';
+          const r = await fetch(`${ORCH}/runs/${id}/events`);
+          const d = await r.json();
+          if (d.error) { evPane.textContent = d.error; return; }
+          evPane.innerHTML = `<h4>Events for run ${id}</h4>` + (d.events || []).map(ev => {
+            const payloadHtml = ev.payload ? `<details><summary>payload</summary><pre>${escapeHtml(JSON.stringify(ev.payload, null, 2))}</pre></details>` : '';
+            return `<div class="event"><span class="stg">stage ${ev.stage}</span><span class="nm">${ev.stage_name}</span><span class="lv ${ev.level}">${ev.level}</span><span class="ms">${escapeHtml(ev.message || '')}${payloadHtml}</span></div>`;
+          }).join('') || '<div class="muted">no events</div>';
+        } else {
+          evRow.style.display = 'none';
+        }
+      });
+    });
+  } catch (e) {
+    errEl.textContent = 'Orchestrator unreachable on :9890 — is it running?';
+  }
+}
+function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c])); }
+document.getElementById('newRun').addEventListener('submit', async (e) => {
+  e.preventDefault();
+  const prompt = document.getElementById('prompt').value.trim();
+  if (!prompt) return;
+  await fetch(ORCH + '/runs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ prompt }) });
+  document.getElementById('prompt').value = '';
+  loadRuns();
+});
+loadRuns();
+setInterval(loadRuns, 4000);
+</script>
+</body>
+</html>
diff --git a/scripts/clickthrough.js b/scripts/clickthrough.js
new file mode 100644
index 0000000..828e058
--- /dev/null
+++ b/scripts/clickthrough.js
@@ -0,0 +1,57 @@
+// Live click-through of The AI Factory viewer.
+// Opens :9891, screenshots the run table, clicks a row to expand events, screenshots that.
+
+const { chromium } = require('playwright');
+const path = require('node:path');
+const fs = require('node:fs/promises');
+
+const VIEWER = 'http://127.0.0.1:9891';
+const ORCH   = 'http://127.0.0.1:9890';
+const OUT    = path.join(__dirname, '..', 'output', 'clickthrough');
+
+(async () => {
+  await fs.mkdir(OUT, { recursive: true });
+  const browser = await chromium.launch({ headless: true });
+  const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 }, deviceScaleFactor: 2 });
+  const page = await ctx.newPage();
+  const consoleMsgs = [];
+  page.on('console', m => consoleMsgs.push(`[${m.type()}] ${m.text()}`));
+  page.on('pageerror', e => consoleMsgs.push(`[PAGEERROR] ${e.message}`));
+
+  console.log('STEP 1: table view');
+  await page.goto(VIEWER, { waitUntil: 'networkidle', timeout: 15000 });
+  await page.waitForTimeout(1500);
+  await page.screenshot({ path: path.join(OUT, '01-table.png'), fullPage: true });
+
+  const rowCount = await page.locator('tr.run-row').count();
+  console.log(`  run rows: ${rowCount}`);
+  const activateCount = await page.locator('.activate-btn').count();
+  console.log(`  activate buttons: ${activateCount}`);
+
+  // Click first row to expand events
+  if (rowCount > 0) {
+    console.log('STEP 2: click first row');
+    await page.locator('tr.run-row').first().click();
+    await page.waitForTimeout(1000);
+    await page.screenshot({ path: path.join(OUT, '02-events-expanded.png'), fullPage: true });
+    const eventCount = await page.locator('.event').count();
+    console.log(`  events visible: ${eventCount}`);
+  }
+
+  await fs.writeFile(path.join(OUT, 'console.log'), consoleMsgs.join('\n'), 'utf8');
+  const summary = {
+    viewer_status: 'reachable',
+    row_count: rowCount,
+    activate_buttons: activateCount,
+    console_messages: consoleMsgs.length,
+    screenshots: ['01-table.png', '02-events-expanded.png']
+  };
+  await fs.writeFile(path.join(OUT, 'summary.json'), JSON.stringify(summary, null, 2));
+  console.log('SUMMARY:', JSON.stringify(summary));
+
+  const health = await (await fetch(`${ORCH}/health`)).json();
+  const runs   = await (await fetch(`${ORCH}/runs`)).json();
+  console.log(`ORCH /health: ${JSON.stringify(health)}  runs: ${runs.runs?.length || 0}`);
+
+  await browser.close();
+})().catch(e => { console.error(e); process.exit(1); });
diff --git a/scripts/run.js b/scripts/run.js
new file mode 100644
index 0000000..0c070d7
--- /dev/null
+++ b/scripts/run.js
@@ -0,0 +1,23 @@
+#!/usr/bin/env node
+// CLI entry: node scripts/run.js "build me a foo"
+
+const prompt = process.argv.slice(2).join(' ').trim();
+if (!prompt) {
+  console.error('Usage: node scripts/run.js "<prompt>"');
+  process.exit(1);
+}
+
+const ORCH = `http://127.0.0.1:${process.env.ORCHESTRATOR_PORT || 9890}`;
+
+(async () => {
+  const res = await fetch(`${ORCH}/runs`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ prompt })
+  });
+  const data = await res.json();
+  console.log(JSON.stringify(data, null, 2));
+})().catch(err => {
+  console.error('Orchestrator unreachable:', err.message);
+  process.exit(2);
+});
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..b315f6b
--- /dev/null
+++ b/server.js
@@ -0,0 +1,201 @@
+// The AI Factory — orchestrator (:9890)
+// Stub server. Pipeline stages are no-ops; see PLAN.md before wiring.
+
+require('dotenv').config();
+const express = require('express');
+const { Pool } = require('pg');
+
+const PG_DATABASE = process.env.PG_DATABASE || 'ai_factory';
+
+// Hard safety guard: this project must NEVER touch dw_unified.
+// Steve's standing rule (2026-04-30) — The AI Factory has its own standalone DB.
+if (PG_DATABASE === 'dw_unified') {
+  console.error('[ai-factory] FATAL: PG_DATABASE=dw_unified is forbidden. Use the standalone `ai_factory` DB.');
+  process.exit(1);
+}
+
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const os = require('node:os');
+const { runPipeline } = require('./src/pipeline');
+const { runMemoryWrite } = require('./src/stages/memory_write');
+
+const CLAUDE_AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents');
+const CLAUDE_SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills');
+
+const app = express();
+app.use(express.json({ limit: '1mb' }));
+
+// Loopback-only CORS so the viewer (:9891) can fetch /runs from this orchestrator (:9890).
+// Server binds to 127.0.0.1 — '*' is safe since no remote origin can hit us.
+app.use((req, res, next) => {
+  res.set('Access-Control-Allow-Origin', '*');
+  res.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
+  res.set('Access-Control-Allow-Headers', 'Content-Type');
+  if (req.method === 'OPTIONS') return res.sendStatus(204);
+  next();
+});
+
+const PORT = Number(process.env.ORCHESTRATOR_PORT || 9890);
+
+// Build pool config; only include `password` if one is actually set, so that
+// Unix-socket peer auth (the default on this Mac) works without forcing SCRAM.
+const poolCfg = {
+  host: process.env.PG_HOST || '/tmp',
+  port: Number(process.env.PG_PORT || 5432),
+  database: PG_DATABASE,
+  user: process.env.PG_USER || 'stevestudio2'
+};
+if (process.env.PG_PASSWORD) poolCfg.password = process.env.PG_PASSWORD;
+const pg = new Pool(poolCfg);
+
+app.get('/health', (_req, res) => {
+  res.json({ ok: true, service: 'ai-factory-orchestrator', port: PORT, db: PG_DATABASE });
+});
+
+app.get('/runs', async (_req, res) => {
+  try {
+    const { rows } = await pg.query(
+      'SELECT id, prompt, artifact_type, artifact_name, status, current_stage, created_at, finished_at FROM runs ORDER BY id DESC LIMIT 100'
+    );
+    res.json({ runs: rows });
+  } catch (err) {
+    res.status(500).json({ error: err.message, hint: 'Did you `createdb ai_factory` and apply sql/001_init.sql?' });
+  }
+});
+
+app.post('/runs', async (req, res) => {
+  const { prompt, artifact_type = null, artifact_name = null } = req.body || {};
+  if (!prompt || typeof prompt !== 'string') {
+    return res.status(400).json({ error: 'prompt (string) required' });
+  }
+  try {
+    const { rows } = await pg.query(
+      'INSERT INTO runs (prompt, artifact_type, artifact_name) VALUES ($1,$2,$3) RETURNING *',
+      [prompt, artifact_type, artifact_name]
+    );
+    const run = rows[0];
+    // Fire-and-forget pipeline; errors are persisted to events/runs.
+    runPipeline(pg, run).catch(err => console.error('[ai-factory] pipeline crash:', err));
+    res.json({ run });
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+/**
+ * Manual activation: copy a sandboxed artifact into ~/.claude/agents or ~/.claude/skills.
+ * Hard-gated:
+ *   - Run status must be `awaiting_activation*` (the critic approved the final version),
+ *     unless `?force=true` is passed.
+ *   - Will not overwrite an existing target file unless `?overwrite=true` is passed.
+ *   - Reads the artifact location from the artifacts table — only files in our sandbox
+ *     under ~/Projects/the-ai-factory/output/ are eligible.
+ */
+app.post('/runs/:id/activate', async (req, res) => {
+  const runId = Number(req.params.id);
+  const force = req.query.force === 'true';
+  const overwrite = req.query.overwrite === 'true';
+  try {
+    const { rows: runRows } = await pg.query('SELECT * FROM runs WHERE id=$1', [runId]);
+    if (!runRows.length) return res.status(404).json({ error: 'run not found' });
+    const run = runRows[0];
+
+    if (!force && !String(run.status).startsWith('awaiting_activation')) {
+      return res.status(409).json({
+        error: `run status is ${run.status}; activation requires awaiting_activation* (or ?force=true)`,
+        hint: 'critic did not approve. Inspect the artifact, fix manually, and pass ?force=true to override.'
+      });
+    }
+
+    const { rows: artRows } = await pg.query(
+      "SELECT * FROM artifacts WHERE run_id=$1 ORDER BY id DESC LIMIT 1",
+      [runId]
+    );
+    if (!artRows.length) return res.status(409).json({ error: 'no artifact for this run' });
+    const art = artRows[0];
+
+    const sandboxRoot = path.resolve(path.join(__dirname, 'output'));
+    const src = path.resolve(art.location);
+    if (!src.startsWith(sandboxRoot + path.sep)) {
+      return res.status(400).json({ error: `refusing to activate from outside sandbox: ${src}` });
+    }
+
+    let dest;
+    if (art.artifact_type === 'subagent') {
+      dest = path.join(CLAUDE_AGENTS_DIR, `${art.artifact_name}.md`);
+    } else if (art.artifact_type === 'skill') {
+      dest = path.join(CLAUDE_SKILLS_DIR, art.artifact_name, 'SKILL.md');
+    } else {
+      return res.status(400).json({ error: `unsupported artifact_type: ${art.artifact_type}` });
+    }
+
+    let existed = false;
+    try { await fs.access(dest); existed = true; } catch { /* ok */ }
+    if (existed && !overwrite) {
+      return res.status(409).json({
+        error: `target already exists: ${dest}`,
+        hint: 'pass ?overwrite=true to replace'
+      });
+    }
+
+    await fs.mkdir(path.dirname(dest), { recursive: true });
+    await fs.copyFile(src, dest);
+
+    await pg.query("UPDATE artifacts SET status='activated', location=$1 WHERE id=$2", [dest, art.id]);
+    await pg.query("UPDATE runs SET status='activated', updated_at=now() WHERE id=$1", [runId]);
+    await pg.query(
+      "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
+      [runId, 10, 'activate', 'info', existed ? 'overwrote target' : 'wrote target', JSON.stringify({ src, dest, force, overwrite })]
+    );
+
+    // Stage 9 — memory_write. Best-effort: log but don't fail activation if the
+    // index append blows up. Spec is recovered from the intake event payload.
+    let memory = null;
+    try {
+      const { rows: specRows } = await pg.query(
+        "SELECT payload FROM events WHERE run_id=$1 AND stage_name='intake' AND message='spec extracted' ORDER BY id DESC LIMIT 1",
+        [runId]
+      );
+      const spec = specRows[0]?.payload?.spec || null;
+      memory = await runMemoryWrite({
+        runId,
+        artifactType: art.artifact_type,
+        artifactName: art.artifact_name,
+        spec,
+        dest
+      });
+      await pg.query(
+        "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
+        [runId, 9, 'memory_write', 'info',
+          memory.skipped ? `skipped: ${memory.reason}` : 'appended to MEMORY.md',
+          JSON.stringify({ line: memory.line })]
+      );
+    } catch (err) {
+      await pg.query(
+        "INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)",
+        [runId, 9, 'memory_write', 'error', 'memory_write failed', JSON.stringify({ error: err.message })]
+      );
+    }
+
+    res.json({ ok: true, src, dest, existed_before: existed, memory });
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+app.get('/runs/:id/events', async (req, res) => {
+  try {
+    const { rows } = await pg.query(
+      'SELECT id, stage, stage_name, level, message, payload, created_at FROM events WHERE run_id=$1 ORDER BY id ASC',
+      [Number(req.params.id)]
+    );
+    res.json({ events: rows });
+  } catch (err) {
+    res.status(500).json({ error: err.message });
+  }
+});
+
+app.listen(PORT, '127.0.0.1', () => {
+  console.log(`[ai-factory] orchestrator listening on http://127.0.0.1:${PORT} (db=${PG_DATABASE})`);
+});
diff --git a/sql/001_init.sql b/sql/001_init.sql
new file mode 100644
index 0000000..1b9a98a
--- /dev/null
+++ b/sql/001_init.sql
@@ -0,0 +1,47 @@
+-- The AI Factory — schema for the standalone `ai_factory` database.
+--
+-- This file MUST be applied against the standalone `ai_factory` DB only.
+-- DO NOT run inside dw_unified. The orchestrator's safety guard will refuse
+-- to boot if PG_DATABASE=dw_unified, but pgsql files don't have that guard,
+-- so verify the connection target before running:
+--
+--   createdb ai_factory                                   # one-time
+--   psql -d ai_factory -f sql/001_init.sql                # apply schema
+--
+-- Tables live in the default `public` schema since this DB is single-purpose.
+
+CREATE TABLE IF NOT EXISTS runs (
+    id              BIGSERIAL PRIMARY KEY,
+    prompt          TEXT NOT NULL,
+    artifact_type   TEXT,
+    artifact_name   TEXT,
+    status          TEXT NOT NULL DEFAULT 'pending',
+    current_stage   INTEGER NOT NULL DEFAULT 0,
+    created_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
+    updated_at      TIMESTAMPTZ NOT NULL DEFAULT now(),
+    finished_at     TIMESTAMPTZ
+);
+
+CREATE TABLE IF NOT EXISTS events (
+    id              BIGSERIAL PRIMARY KEY,
+    run_id          BIGINT NOT NULL REFERENCES runs(id) ON DELETE CASCADE,
+    stage           INTEGER NOT NULL,
+    stage_name      TEXT NOT NULL,
+    level           TEXT NOT NULL DEFAULT 'info',
+    message         TEXT,
+    payload         JSONB,
+    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE TABLE IF NOT EXISTS artifacts (
+    id              BIGSERIAL PRIMARY KEY,
+    run_id          BIGINT REFERENCES runs(id) ON DELETE SET NULL,
+    artifact_type   TEXT NOT NULL,
+    artifact_name   TEXT NOT NULL,
+    location        TEXT NOT NULL,
+    status          TEXT NOT NULL DEFAULT 'draft',
+    created_at      TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS events_run_idx ON events (run_id, stage);
+CREATE INDEX IF NOT EXISTS artifacts_name_idx ON artifacts (artifact_type, artifact_name);
diff --git a/src/llm.js b/src/llm.js
new file mode 100644
index 0000000..b980496
--- /dev/null
+++ b/src/llm.js
@@ -0,0 +1,72 @@
+// The AI Factory — LLM adapters.
+//
+// Hybrid design (2026-04-30): Ollama (local, free, fast) for mechanical stages;
+// Claude CLI subprocess (Steve's Max sub, no per-token charge) for the critic pass.
+// No Anthropic API calls — Steve's standing rule "if api costs money.. no".
+
+const { spawn } = require('node:child_process');
+
+const OLLAMA_HOST = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
+const CLAUDE_CLI = process.env.CLAUDE_CLI || 'claude';
+
+/**
+ * Call a local Ollama model. If `format` is 'json', the model is forced to
+ * emit valid JSON and the result is parsed before returning.
+ */
+async function ollama({ model, system, prompt, format = null, temperature = 0.2 }) {
+  const body = {
+    model,
+    stream: false,
+    options: { temperature },
+    messages: [
+      ...(system ? [{ role: 'system', content: system }] : []),
+      { role: 'user', content: prompt }
+    ]
+  };
+  if (format === 'json') body.format = 'json';
+
+  const res = await fetch(`${OLLAMA_HOST}/api/chat`, {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(body)
+  });
+  if (!res.ok) throw new Error(`ollama ${model} http ${res.status}: ${await res.text()}`);
+  const data = await res.json();
+  const content = data?.message?.content ?? '';
+  if (format === 'json') {
+    try { return JSON.parse(content); }
+    catch (e) { throw new Error(`ollama ${model} did not return valid JSON: ${content.slice(0, 200)}`); }
+  }
+  return content;
+}
+
+/**
+ * Run Claude CLI as a one-shot subprocess. Used only for the Manager critic pass
+ * where quality matters more than speed. Counts against Steve's Max quota.
+ *
+ * Pipes the prompt via stdin instead of `-p prompt` arg style — avoids silent
+ * exit-1 failures on prompts containing JSON, newlines, or non-ASCII chars.
+ * Discovered while building Visual Factory 2026-04-30.
+ */
+function claudeCli({ prompt, timeoutMs = 120000 }) {
+  return new Promise((resolve, reject) => {
+    const child = spawn(CLAUDE_CLI, ['-p'], { stdio: ['pipe', 'pipe', 'pipe'] });
+    let stdout = '';
+    let stderr = '';
+    const timer = setTimeout(() => {
+      child.kill('SIGTERM');
+      reject(new Error(`claude CLI timed out after ${timeoutMs}ms`));
+    }, timeoutMs);
+    child.stdout.on('data', d => { stdout += d; });
+    child.stderr.on('data', d => { stderr += d; });
+    child.on('error', err => { clearTimeout(timer); reject(new Error(`claude CLI spawn error: ${err.message}`)); });
+    child.on('close', code => {
+      clearTimeout(timer);
+      if (code !== 0) return reject(new Error(`claude CLI exit ${code}: ${stderr.slice(0, 400) || '(empty stderr)'}`));
+      resolve(stdout.trim());
+    });
+    child.stdin.end(prompt);
+  });
+}
+
+module.exports = { ollama, claudeCli };
diff --git a/src/pipeline.js b/src/pipeline.js
new file mode 100644
index 0000000..6ecf205
--- /dev/null
+++ b/src/pipeline.js
@@ -0,0 +1,179 @@
+// The AI Factory — pipeline runner.
+// 11 stages mirroring Site Factory.
+// Parallelization (2026-04-30): research ‖ scaffold, document ‖ critic.
+
+const { runIntake }    = require('./stages/intake');
+const { runResearch }  = require('./stages/research');
+const { runScaffold }  = require('./stages/scaffold');
+const { runDocument }  = require('./stages/document');
+const { runSmokeTest } = require('./stages/smoke_test');
+const { runCritic }    = require('./stages/critic');
+const { runIterate }   = require('./stages/iterate');
+
+const STAGES = [
+  'intake', 'research', 'scaffold', 'wire', 'smoke_test',
+  'critic', 'iterate', 'document', 'memory_write', 'activate', 'watchdog_hook'
+];
+
+async function logEvent(pg, runId, stage, stageName, level, message, payload) {
+  await pg.query(
+    'INSERT INTO events (run_id, stage, stage_name, level, message, payload) VALUES ($1,$2,$3,$4,$5,$6)',
+    [runId, stage, stageName, level, message, payload ? JSON.stringify(payload) : null]
+  );
+}
+async function setStage(pg, runId, stage, status) {
+  await pg.query('UPDATE runs SET current_stage=$1, status=$2, updated_at=now() WHERE id=$3', [stage, status, runId]);
+}
+async function finishRun(pg, runId, status) {
+  await pg.query('UPDATE runs SET status=$1, finished_at=now(), updated_at=now() WHERE id=$2', [status, runId]);
+}
+async function setArtifactMeta(pg, runId, type, name) {
+  await pg.query('UPDATE runs SET artifact_type=$1, artifact_name=$2, updated_at=now() WHERE id=$3', [type, name, runId]);
+}
+
+async function runPipeline(pg, run) {
+  const runId = run.id;
+
+  try {
+    // === Stage 1: intake ===============================================
+    await setStage(pg, runId, 1, 'running');
+    await logEvent(pg, runId, 1, 'intake', 'info', 'starting', null);
+    const { spec, errors: intakeErrors } = await runIntake({ prompt: run.prompt });
+    if (intakeErrors.length) {
+      await logEvent(pg, runId, 1, 'intake', 'error', 'spec validation failed', { spec, errors: intakeErrors });
+      await finishRun(pg, runId, 'failed_intake');
+      return;
+    }
+    await setArtifactMeta(pg, runId, spec.artifact_type, spec.artifact_name);
+    await logEvent(pg, runId, 1, 'intake', 'info', 'spec extracted', { spec });
+
+    // === Stages 2 + 3 in parallel: research ‖ scaffold =================
+    await setStage(pg, runId, 3, 'running'); // surface the longer-running one
+    const [researchResult, scaffold] = await Promise.all([
+      runResearch({ spec }).catch(e => ({ error: e.message })),
+      runScaffold({ runId, spec })
+    ]);
+
+    // log research outcome
+    if (researchResult.error) {
+      await logEvent(pg, runId, 2, 'research', 'warn', 'research failed (non-blocking)', { error: researchResult.error });
+    } else {
+      const conflictMsg = researchResult.exact_conflict
+        ? `EXACT NAME CONFLICT — ~/.claude/${spec.artifact_type === 'subagent' ? 'agents' : 'skills'}/${spec.artifact_name} already exists`
+        : researchResult.matches.length
+          ? `${researchResult.matches.length} similar artifact(s) found`
+          : 'no conflicts';
+      await logEvent(pg, runId, 2, 'research',
+        researchResult.exact_conflict ? 'warn' : 'info',
+        conflictMsg,
+        researchResult);
+    }
+
+    // log scaffold outcome
+    if (scaffold.errors.length) {
+      await logEvent(pg, runId, 3, 'scaffold', 'warn', 'validation issues', { errors: scaffold.errors, outPath: scaffold.outPath });
+    } else {
+      await logEvent(pg, runId, 3, 'scaffold', 'info', 'wrote file', { outPath: scaffold.outPath, bytes: scaffold.content.length });
+    }
+    await pg.query(
+      'INSERT INTO artifacts (run_id, artifact_type, artifact_name, location, status) VALUES ($1,$2,$3,$4,$5)',
+      [runId, spec.artifact_type, spec.artifact_name, scaffold.outPath, 'draft']
+    );
+
+    // === Stage 4: wire (no-op for v1 subagent/skill artifacts) =========
+    await setStage(pg, runId, 4, 'running');
+    await logEvent(pg, runId, 4, 'wire', 'info', 'no-op for v1 artifacts (no external registration needed)', null);
+
+    // === Stage 5: smoke_test (cheap, local) ============================
+    await setStage(pg, runId, 5, 'running');
+    const smoke = await runSmokeTest({ outPath: scaffold.outPath, spec });
+    await logEvent(pg, runId, 5, 'smoke_test',
+      smoke.passing ? 'info' : 'warn',
+      `${smoke.passed}/${smoke.total} checks passing`,
+      smoke);
+
+    // === Stages 6 + 8 in parallel: critic ‖ document ===================
+    // Document only runs for skills — for subagents we just skip and Promise.all
+    // gets back an immediate { skipped: true }.
+    await setStage(pg, runId, 6, 'running');
+    await logEvent(pg, runId, 6, 'critic', 'info', 'starting (claude CLI subprocess) ‖ document', null);
+    const [criticResult, documentResult] = await Promise.all([
+      runCritic({ artifactType: spec.artifact_type, content: scaffold.content, spec })
+        .catch(e => ({ approved: false, score: 0, issues: [`critic failed: ${e.message}`], fix_instructions: [] })),
+      runDocument({ runId, spec, scaffoldOutPath: scaffold.outPath })
+        .catch(e => ({ skipped: true, error: e.message }))
+    ]);
+
+    let review = criticResult;
+    await logEvent(pg, runId, 6, 'critic',
+      review.approved ? 'info' : 'warn',
+      `score=${review.score} approved=${review.approved}`,
+      { review });
+    await logEvent(pg, runId, 8, 'document',
+      documentResult.skipped ? 'info' : 'info',
+      documentResult.skipped ? `skipped: ${documentResult.reason || documentResult.error || 'n/a'}` : 'wrote README.md',
+      documentResult);
+
+    // === Stage 7: iterate (only if rejected, with re-critic) ===========
+    await setStage(pg, runId, 7, 'running');
+    let finalReview = review;
+    let didIterate = false;
+    if (!review.approved && review.fix_instructions.length) {
+      await logEvent(pg, runId, 7, 'iterate', 'info', `applying ${review.fix_instructions.length} fixes`, { fixes: review.fix_instructions });
+      try {
+        const iter = await runIterate({ outPath: scaffold.outPath, content: scaffold.content, spec, review });
+        scaffold.content = iter.content;
+        didIterate = true;
+        await logEvent(pg, runId, 7, 'iterate', 'info', 'rewrote file', { bytes: iter.content.length });
+
+        await logEvent(pg, runId, 7, 'iterate', 'info', 're-running critic on v2', null);
+        try {
+          const review2 = await runCritic({ artifactType: spec.artifact_type, content: iter.content, spec });
+          finalReview = review2;
+          await logEvent(pg, runId, 7, 'iterate', review2.approved ? 'info' : 'warn',
+            `re-critic: score=${review2.score} approved=${review2.approved} (was ${review.score})`,
+            { review: review2 });
+        } catch (err) {
+          await logEvent(pg, runId, 7, 'iterate', 'error', 're-critic failed', { error: err.message });
+        }
+      } catch (err) {
+        await logEvent(pg, runId, 7, 'iterate', 'error', 'iterate failed', { error: err.message });
+      }
+    } else {
+      await logEvent(pg, runId, 7, 'iterate', 'info', review.approved ? 'critic approved — no changes' : 'no fix_instructions', null);
+    }
+
+    // Re-run smoke test on revised content if iterate ran
+    if (didIterate) {
+      const smoke2 = await runSmokeTest({ outPath: scaffold.outPath, spec });
+      await logEvent(pg, runId, 5, 'smoke_test',
+        smoke2.passing ? 'info' : 'warn',
+        `post-iterate: ${smoke2.passed}/${smoke2.total} checks passing`,
+        smoke2);
+    }
+
+    // Persist the final review on the artifact row so activate can gate on it.
+    await pg.query("UPDATE artifacts SET status=$1 WHERE run_id=$2",
+      [finalReview.approved ? 'awaiting_activation' : 'unapproved', runId]);
+
+    // === Stage 9 + 10: deferred to POST /runs/:id/activate =============
+    await setStage(pg, runId, 9, 'running');
+    await logEvent(pg, runId, 9, 'memory_write', 'info', 'deferred — fires inside activate handler', null);
+    await setStage(pg, runId, 10, 'running');
+    await logEvent(pg, runId, 10, 'activate', 'info', 'awaiting manual activation (POST /runs/:id/activate)', null);
+
+    // === Stage 11: watchdog_hook (no-op for v1) ========================
+    await setStage(pg, runId, 11, 'running');
+    await logEvent(pg, runId, 11, 'watchdog_hook', 'info', 'no-op for v1 artifacts (only relevant for service-type artifacts in v2)', null);
+
+    const finalStatus = finalReview.approved
+      ? (didIterate ? 'awaiting_activation_v2' : 'awaiting_activation')
+      : 'completed_unapproved';
+    await finishRun(pg, runId, finalStatus);
+  } catch (err) {
+    await logEvent(pg, runId, run.current_stage || 0, STAGES[run.current_stage - 1] || 'unknown', 'error', err.message, { stack: err.stack });
+    await finishRun(pg, runId, 'failed');
+  }
+}
+
+module.exports = { STAGES, runPipeline };
diff --git a/src/stages/critic.js b/src/stages/critic.js
new file mode 100644
index 0000000..605ce94
--- /dev/null
+++ b/src/stages/critic.js
@@ -0,0 +1,57 @@
+// Stage 6 — Manager critic. Single Claude CLI subprocess call (Steve's Max sub).
+// Reads the scaffolded file, returns a JSON review.
+
+const { claudeCli } = require('../llm');
+
+const CRITIC_PROMPT = (artifactType, content, spec) => `You are reviewing a draft Claude Code ${artifactType} file produced by an automated pipeline. Be a strict reviewer.
+
+The artifact spec was:
+${JSON.stringify(spec, null, 2)}
+
+The draft file content:
+---BEGIN FILE---
+${content}
+---END FILE---
+
+Evaluate against Anthropic's guidelines for Claude Code ${artifactType}s:
+- YAML frontmatter is valid (name, description${artifactType === 'subagent' ? ', tools, model' : ''})
+- description is specific enough to trigger the right way (not too vague, not too narrow)
+- ${artifactType === 'subagent' ? 'tools list is the minimum needed (no over-granting)' : 'WHEN to use is unambiguous'}
+- Body sections are present and useful
+- No hallucinated file paths, env vars, or commands
+- No prose padding; every line earns its place
+
+Return ONLY a JSON object, no prose, no fences:
+{
+  "approved": <true|false>,
+  "score": <0-100>,
+  "issues": [<short string per concrete problem>],
+  "fix_instructions": [<short string per actionable revision the next stage should apply>]
+}`;
+
+async function runCritic({ artifactType, content, spec }) {
+  const raw = await claudeCli({
+    prompt: CRITIC_PROMPT(artifactType, content, spec),
+    timeoutMs: 180000
+  });
+
+  // Claude CLI may wrap JSON in fences or add prose; pull out the first {...} block.
+  const match = raw.match(/\{[\s\S]*\}/);
+  if (!match) {
+    return { approved: false, score: 0, issues: ['critic returned no JSON'], fix_instructions: [], raw_excerpt: raw.slice(0, 300) };
+  }
+  let review;
+  try { review = JSON.parse(match[0]); }
+  catch (e) {
+    return { approved: false, score: 0, issues: [`critic JSON parse failed: ${e.message}`], fix_instructions: [], raw_excerpt: match[0].slice(0, 300) };
+  }
+
+  return {
+    approved: !!review.approved,
+    score: Number(review.score) || 0,
+    issues: Array.isArray(review.issues) ? review.issues : [],
+    fix_instructions: Array.isArray(review.fix_instructions) ? review.fix_instructions : []
+  };
+}
+
+module.exports = { runCritic };
diff --git a/src/stages/document.js b/src/stages/document.js
new file mode 100644
index 0000000..cc8861b
--- /dev/null
+++ b/src/stages/document.js
@@ -0,0 +1,42 @@
+// Stage 8 — document. For skills only, drop a README.md into the artifact dir
+// alongside SKILL.md so Steve can read what it does without opening SKILL.md.
+// No-op for subagents (they're a single .md and self-documenting).
+
+const fs = require('node:fs/promises');
+const path = require('node:path');
+
+async function runDocument({ runId, spec, scaffoldOutPath }) {
+  if (spec.artifact_type !== 'skill') return { skipped: true, reason: 'subagent — README not needed' };
+
+  const dir = path.dirname(scaffoldOutPath);
+  const readmePath = path.join(dir, 'README.md');
+
+  const triggers = (spec.triggers || []).length
+    ? spec.triggers.map(t => `- "${t}"`).join('\n')
+    : '- (none specified in spec)';
+  const oos = (spec.out_of_scope || []).length
+    ? spec.out_of_scope.map(o => `- ${o}`).join('\n')
+    : '- (none specified)';
+
+  const content = `# ${spec.artifact_name}
+
+${spec.scope || ''}
+
+## Triggers
+
+${triggers}
+
+## Out of scope
+
+${oos}
+
+---
+
+Generated by The AI Factory · run #${runId} · ${new Date().toISOString().slice(0, 10)}
+`;
+
+  await fs.writeFile(readmePath, content, 'utf8');
+  return { skipped: false, readmePath };
+}
+
+module.exports = { runDocument };
diff --git a/src/stages/intake.js b/src/stages/intake.js
new file mode 100644
index 0000000..9604aff
--- /dev/null
+++ b/src/stages/intake.js
@@ -0,0 +1,42 @@
+// Stage 1 — intake. Free-text prompt → structured artifact spec via local Ollama.
+
+const { ollama } = require('../llm');
+
+const SYSTEM = `You convert a Steve-style request into a structured plan for "The AI Factory".
+The Factory builds Claude Code subagents and /skills (v1 scope).
+Return ONLY a JSON object with these keys:
+  artifact_type   one of: "subagent" | "skill"
+  artifact_name   short kebab-case identifier (e.g. "freezer-watcher")
+  surface         where it runs: "claude-code" (subagents/skills) | "cli" | "service"
+  scope           one short sentence describing what the artifact does
+  triggers        array of short phrases that should activate it (max 5)
+  out_of_scope    array of things it explicitly should NOT do (max 3)
+No prose, no markdown, no code fences. Just the JSON.`;
+
+async function runIntake({ prompt }) {
+  const spec = await ollama({
+    model: 'qwen3:14b',
+    system: SYSTEM,
+    prompt,
+    format: 'json',
+    temperature: 0.1
+  });
+
+  // Normalize and validate the shape so downstream stages can trust it.
+  const errors = [];
+  if (!spec.artifact_type || !['subagent', 'skill'].includes(spec.artifact_type)) {
+    errors.push(`artifact_type must be "subagent" or "skill", got ${JSON.stringify(spec.artifact_type)}`);
+  }
+  if (!spec.artifact_name || !/^[a-z0-9-]{2,60}$/.test(spec.artifact_name)) {
+    errors.push(`artifact_name must be kebab-case 2-60 chars, got ${JSON.stringify(spec.artifact_name)}`);
+  }
+  if (!spec.scope || typeof spec.scope !== 'string') {
+    errors.push('scope is required (string)');
+  }
+  spec.triggers = Array.isArray(spec.triggers) ? spec.triggers.slice(0, 5) : [];
+  spec.out_of_scope = Array.isArray(spec.out_of_scope) ? spec.out_of_scope.slice(0, 3) : [];
+
+  return { spec, errors };
+}
+
+module.exports = { runIntake };
diff --git a/src/stages/iterate.js b/src/stages/iterate.js
new file mode 100644
index 0000000..bd29228
--- /dev/null
+++ b/src/stages/iterate.js
@@ -0,0 +1,39 @@
+// Stage 7 — iterate. If critic rejected, qwen3:14b applies the fix_instructions
+// to the scaffolded file and overwrites it. Single retry only.
+
+const fs = require('node:fs/promises');
+const { ollama } = require('../llm');
+
+const ITERATE_SYSTEM = `You revise a Claude Code subagent or skill file in place. Output ONLY the revised file content — same format, same frontmatter shape, no code fences, no commentary.`;
+
+async function runIterate({ outPath, content, spec, review }) {
+  if (review.approved) return { changed: false, content };
+
+  const userPrompt = `Original spec:
+${JSON.stringify(spec, null, 2)}
+
+Current file content:
+---BEGIN FILE---
+${content}
+---END FILE---
+
+Issues found by critic:
+${review.issues.map((i, n) => `${n + 1}. ${i}`).join('\n')}
+
+Apply these fixes:
+${review.fix_instructions.map((f, n) => `${n + 1}. ${f}`).join('\n')}
+
+Output the revised file content now.`;
+
+  const revised = (await ollama({
+    model: 'qwen3:14b',
+    system: ITERATE_SYSTEM,
+    prompt: userPrompt,
+    temperature: 0.2
+  })).replace(/^```(?:markdown|md)?\s*\n/, '').replace(/\n```\s*$/, '').trim();
+
+  await fs.writeFile(outPath, revised, 'utf8');
+  return { changed: true, content: revised };
+}
+
+module.exports = { runIterate };
diff --git a/src/stages/memory_write.js b/src/stages/memory_write.js
new file mode 100644
index 0000000..e842a4c
--- /dev/null
+++ b/src/stages/memory_write.js
@@ -0,0 +1,53 @@
+// Stage 9 — memory_write. Appends one line to Steve's MEMORY.md index after a
+// successful activation. We do NOT write a separate memory file per artifact —
+// that would pollute the memory dir; the index entry is enough for future
+// sessions to learn the artifact exists.
+
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const os = require('node:os');
+
+const MEMORY_INDEX = path.join(
+  os.homedir(),
+  '.claude',
+  'projects',
+  '-Users-stevestudio2',
+  'memory',
+  'MEMORY.md'
+);
+
+function todayStamp() {
+  return new Date().toISOString().slice(0, 10); // YYYY-MM-DD
+}
+
+function buildLine({ runId, artifactType, artifactName, spec, dest }) {
+  const scope = (spec?.scope || '').replace(/\s+/g, ' ').trim();
+  const triggers = Array.isArray(spec?.triggers) && spec.triggers.length
+    ? ` triggers: ${spec.triggers.slice(0, 3).join(', ')};`
+    : '';
+  const surface = artifactType === 'subagent' ? '~/.claude/agents/' : '~/.claude/skills/';
+  return `- AI Factory shipped: \`${artifactName}\` (${artifactType}) — ${scope}${triggers} [run #${runId}, ${todayStamp()}, lives in ${surface}]`;
+}
+
+async function appendMemoryIndex(line) {
+  let existing = '';
+  try { existing = await fs.readFile(MEMORY_INDEX, 'utf8'); }
+  catch (e) {
+    if (e.code !== 'ENOENT') throw e;
+  }
+  // Avoid double-entry if activate is called twice for the same artifact.
+  if (existing.includes(line.split(' [run #')[0])) {
+    return { skipped: true, reason: 'already present' };
+  }
+  const trailing = existing.endsWith('\n') ? '' : (existing ? '\n' : '');
+  await fs.writeFile(MEMORY_INDEX, existing + trailing + line + '\n', 'utf8');
+  return { skipped: false };
+}
+
+async function runMemoryWrite({ runId, artifactType, artifactName, spec, dest }) {
+  const line = buildLine({ runId, artifactType, artifactName, spec, dest });
+  const result = await appendMemoryIndex(line);
+  return { line, ...result };
+}
+
+module.exports = { runMemoryWrite, MEMORY_INDEX };
diff --git a/src/stages/research.js b/src/stages/research.js
new file mode 100644
index 0000000..0d0ffbd
--- /dev/null
+++ b/src/stages/research.js
@@ -0,0 +1,63 @@
+// Stage 2 — research. Scan ~/.claude/agents/ and ~/.claude/skills/ for artifacts
+// that look similar to spec.artifact_name. Non-blocking: this only WARNS via an
+// event so Steve doesn't accidentally activate a near-duplicate. Activation can
+// still proceed; the warning lives in the events log.
+
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const os = require('node:os');
+
+const AGENTS_DIR = path.join(os.homedir(), '.claude', 'agents');
+const SKILLS_DIR = path.join(os.homedir(), '.claude', 'skills');
+
+async function listExisting() {
+  const out = [];
+  try {
+    for (const f of await fs.readdir(AGENTS_DIR)) {
+      if (f.endsWith('.md')) out.push({ type: 'subagent', name: f.replace(/\.md$/, '') });
+    }
+  } catch (e) { if (e.code !== 'ENOENT') throw e; }
+  try {
+    for (const d of await fs.readdir(SKILLS_DIR, { withFileTypes: true })) {
+      if (!d.isDirectory() || d.name.startsWith('_') || d.name.startsWith('.')) continue;
+      out.push({ type: 'skill', name: d.name });
+    }
+  } catch (e) { if (e.code !== 'ENOENT') throw e; }
+  return out;
+}
+
+// Bigram Jaccard similarity, 0.0 – 1.0. Cheap, deterministic, no LLM call.
+function similarity(a, b) {
+  const bigrams = (s) => {
+    const out = new Set();
+    const lower = s.toLowerCase();
+    for (let i = 0; i < lower.length - 1; i++) out.add(lower.slice(i, i + 2));
+    return out;
+  };
+  const A = bigrams(a);
+  const B = bigrams(b);
+  if (!A.size || !B.size) return 0;
+  let inter = 0;
+  for (const x of A) if (B.has(x)) inter++;
+  const union = A.size + B.size - inter;
+  return union === 0 ? 0 : inter / union;
+}
+
+async function runResearch({ spec }) {
+  const existing = await listExisting();
+  const matches = [];
+  for (const e of existing) {
+    if (e.type !== spec.artifact_type) continue;
+    if (e.name === spec.artifact_name) {
+      matches.push({ ...e, similarity: 1.0, conflict: 'exact-name' });
+      continue;
+    }
+    const s = similarity(e.name, spec.artifact_name);
+    if (s >= 0.5) matches.push({ ...e, similarity: Number(s.toFixed(2)) });
+  }
+  matches.sort((a, b) => b.similarity - a.similarity);
+  const exact = matches.find(m => m.conflict === 'exact-name');
+  return { existing_count: existing.length, matches: matches.slice(0, 5), exact_conflict: !!exact };
+}
+
+module.exports = { runResearch };
diff --git a/src/stages/scaffold.js b/src/stages/scaffold.js
new file mode 100644
index 0000000..36e1be3
--- /dev/null
+++ b/src/stages/scaffold.js
@@ -0,0 +1,93 @@
+// Stage 3 — scaffold. qwen3:14b template-fills a Claude Code subagent .md or skill SKILL.md
+// and writes it to a sandbox dir. Never writes to ~/.claude/{agents,skills}/ — that's stage 10.
+
+const fs = require('node:fs/promises');
+const path = require('node:path');
+const { ollama } = require('../llm');
+
+const SANDBOX_ROOT = path.join(__dirname, '..', '..', 'output');
+
+const SUBAGENT_SYSTEM = `You write Claude Code subagent files. Output ONLY the raw file content, no code fences, no commentary.
+
+Format:
+---
+name: <kebab-name from spec>
+description: <one detailed paragraph that includes WHEN to use this subagent and what it does. Use "PROACTIVELY" if it should be invoked without explicit user request. 80-200 words.>
+tools: <comma-separated tools the subagent needs: Read, Write, Edit, Bash, Grep, WebSearch, WebFetch — pick only what is needed>
+model: sonnet
+---
+
+<system-prompt body in markdown. Sections to include:
+  ## Your job  (1 sentence)
+  ## Inputs  (what the orchestrator hands you)
+  ## What to check / do  (bullet list)
+  ## Output format  (exact structure of what you return)
+  ## Out of scope  (from spec.out_of_scope)
+Keep it under 400 words total.>`;
+
+const SKILL_SYSTEM = `You write Claude Code skill SKILL.md files. Output ONLY the raw file content, no code fences, no commentary.
+
+Format:
+---
+name: <kebab-name from spec>
+description: <one detailed paragraph including WHEN this skill should auto-trigger. Mention specific trigger phrases from spec.triggers. 60-180 words.>
+---
+
+# <Title from name>
+
+<Skill body in markdown. Sections:
+  ## When to use  (bullet list of triggers)
+  ## What it does  (bullet list)
+  ## How to invoke  (concrete steps or commands)
+  ## Out of scope  (from spec.out_of_scope)
+Keep under 350 words.>`;
+
+async function runScaffold({ runId, spec }) {
+  const isSubagent = spec.artifact_type === 'subagent';
+  const system = isSubagent ? SUBAGENT_SYSTEM : SKILL_SYSTEM;
+
+  const userPrompt = `Spec:
+${JSON.stringify(spec, null, 2)}
+
+Write the file content now.`;
+
+  const content = await ollama({
+    model: 'qwen3:14b',
+    system,
+    prompt: userPrompt,
+    temperature: 0.2
+  });
+
+  const cleaned = stripFences(content).trim();
+
+  const runDir = path.join(SANDBOX_ROOT, `run-${runId}`);
+  await fs.mkdir(runDir, { recursive: true });
+  const outPath = isSubagent
+    ? path.join(runDir, `${spec.artifact_name}.md`)
+    : path.join(runDir, spec.artifact_name, 'SKILL.md');
+  await fs.mkdir(path.dirname(outPath), { recursive: true });
+  await fs.writeFile(outPath, cleaned, 'utf8');
+
+  const errors = validateScaffold(cleaned, spec);
+  return { outPath, content: cleaned, errors };
+}
+
+function stripFences(s) {
+  // qwen3:14b sometimes wraps output in ```markdown fences despite instructions.
+  return s.replace(/^```(?:markdown|md)?\s*\n/, '').replace(/\n```\s*$/, '');
+}
+
+function validateScaffold(content, spec) {
+  const errors = [];
+  if (!/^---\s*\n/.test(content)) errors.push('missing YAML frontmatter opening ---');
+  if (!/\n---\s*\n/.test(content)) errors.push('missing YAML frontmatter closing ---');
+  if (!new RegExp(`name:\\s*${escapeRe(spec.artifact_name)}`).test(content)) {
+    errors.push(`frontmatter name does not match spec.artifact_name (${spec.artifact_name})`);
+  }
+  if (!/description:\s*\S/.test(content)) errors.push('frontmatter missing description');
+  return errors;
+}
+
+function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
+
+module.exports = { runScaffold };
diff --git a/src/stages/smoke_test.js b/src/stages/smoke_test.js
new file mode 100644
index 0000000..4cb7c6b
--- /dev/null
+++ b/src/stages/smoke_test.js
@@ -0,0 +1,35 @@
+// Stage 5 — smoke_test. Cheap structural validation on the final scaffold file.
+// Pure-local, deterministic — no LLM, no Claude CLI. Catches the most common
+// failure modes (missing frontmatter, name drift, empty body) without burning
+// quota or wall-clock.
+
+const fs = require('node:fs/promises');
+
+function escapeRe(s) { return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
+
+async function runSmokeTest({ outPath, spec }) {
+  const content = await fs.readFile(outPath, 'utf8');
+  const checks = [
+    { name: 'has_frontmatter_open',  pass: /^---\s*\n/.test(content) },
+    { name: 'has_frontmatter_close', pass: /\n---\s*\n/.test(content) },
+    { name: 'name_matches_spec',     pass: new RegExp(`^name:\\s*${escapeRe(spec.artifact_name)}\\s*$`, 'm').test(content) },
+    { name: 'has_description',       pass: /^description:\s*\S/m.test(content) },
+    { name: 'body_nonempty',         pass: content.length > 200 },
+    { name: 'no_unrendered_template', pass: !/<<<|>>>|TODO_FILL|XXX_REPLACE/.test(content) }
+  ];
+  if (spec.artifact_type === 'subagent') {
+    checks.push({ name: 'has_tools',  pass: /^tools:\s*\S/m.test(content) });
+    checks.push({ name: 'has_model',  pass: /^model:\s*(sonnet|opus|haiku)\s*$/m.test(content) });
+  }
+
+  const passed = checks.filter(c => c.pass).length;
+  return {
+    checks,
+    passed,
+    total: checks.length,
+    passing: passed === checks.length,
+    failing_checks: checks.filter(c => !c.pass).map(c => c.name)
+  };
+}
+
+module.exports = { runSmokeTest };
diff --git a/viewer.js b/viewer.js
new file mode 100644
index 0000000..17603ec
--- /dev/null
+++ b/viewer.js
@@ -0,0 +1,15 @@
+// The AI Factory — viewer (:9891)
+// Static viewer that polls the orchestrator's /runs endpoint.
+
+require('dotenv').config();
+const express = require('express');
+const path = require('path');
+
+const app = express();
+const PORT = Number(process.env.VIEWER_PORT || 9891);
+
+app.use(express.static(path.join(__dirname, 'public')));
+
+app.listen(PORT, '127.0.0.1', () => {
+  console.log(`[ai-factory] viewer listening on http://127.0.0.1:${PORT}`);
+});

(oldest)  ·  back to The Ai Factory  ·  tighten .gitignore: add missing standing-rule patterns (tmp/ 20486fe →