# Factory

What lives here: the job-queue infrastructure that coordinates local Claude Code daemons. Worker scripts (workers/), Cloudflare Queue job definitions (queues/), smoke-test harnesses (smoke-tests/), prompt templates (prompts/, always starting with DO-NOT-CHANGE-EVER.md verbatim), and the preference model (preference-model.js).

What does NOT live here: canonical component code (lives in components/), theme compositions (lives in themes/), voice pipeline UI (lives in voice/), deployed site artifacts (lives in deploys/).

Who reads from here: Cloudflare Worker (thought-router.js) serves the queue API. Local Claude Code daemons poll it for work, claim slots, and post results back.

Who writes to here: human and agent authors adding worker scripts, queue configs, smoke test cases, and prompt templates; the voice UI curation layer (PASS/FAIL/EDIT buttons call recordSignal() in preference-model.js).

Cross-references: see ../APEX-MOTION-ENGINE-VISION.md (Level 10 of the Voice Command Center ladder describes this module.)

---

## QUEUE-WORKER MODEL (pivoted from direct API calls)

The factory Worker no longer calls the Anthropic API. Instead it acts as a JOB QUEUE. Local Claude Code daemons do the generation work and report results back.

### Why

Zero ongoing Cloudflare Worker API spend. Daemons run on local machines with their own Claude subscriptions. The Worker is purely a coordination layer: intake, state, streaming, cleanup.

### Flow

```
Voice Center UI
  --> POST /api/factory/thought   (Worker creates 5 pending slots in D1)

Local daemon (Claude Code)
  --> GET  /api/factory/queue     (read-only: see pending slots)
  --> POST /api/factory/claim     (mark slot 'claimed', starts 5-min lease)
  --> [daemon generates component, pushes branch + commit]
  --> POST /api/factory/complete  (mark slot 'shipped' or 'failed')

Worker (cron every 60s)
  --> releases stale claims (lease_expires_at < now) back to 'pending'

Voice Center UI
  --> GET  /api/factory/stream/:thought_id  (SSE: variant_claimed, variant_complete, thought_complete)
```

### Endpoints

| Method | Path | Description |
|---|---|---|
| POST | /api/factory/thought | Intake. Body: {id, text, ts}. Creates 5 pending slots. Returns {ok, thought_id, variants_enqueued: 5}. |
| GET  | /api/factory/queue | Daemon poll. Params: worker_id, limit (1-20). Returns up to limit unclaimed slots. READ-ONLY. |
| POST | /api/factory/claim | Body: {slot_id, worker_id}. Marks slot 'claimed' with 5-min lease. Returns 409 if held by another worker. |
| POST | /api/factory/complete | Body: {slot_id, worker_id, branch_name, commit_hash, files_pushed[], smoke_test_pass}. Marks 'shipped' or 'failed'. Updates thought counter. |
| POST | /api/factory/release | Body: {slot_id, worker_id, reason}. Releases claim back to 'pending' (graceful daemon shutdown). |
| GET  | /api/factory/stream/:id | SSE. Emits variant_claimed, variant_complete, thought_complete. Closes at thought_complete. |
| GET  | /api/factory/stats | Returns {slots: {pending,claimed,shipped,failed}, thoughts: {...}} for dashboard. |

### Slot lifecycle

```
pending --> claimed (POST /api/factory/claim)
claimed --> shipped (POST /api/factory/complete with smoke_test_pass: true)
claimed --> failed  (POST /api/factory/complete with smoke_test_pass: false)
claimed --> pending (POST /api/factory/release OR cron stale-lease cleanup)
```

### Lease

Claims expire after 300 seconds. The cron trigger (every 60s) resets expired claims to 'pending' so another daemon can pick them up. A daemon that finishes early should call POST /api/factory/release for a clean handoff rather than waiting for expiry.

### No secrets required

The queue-worker model requires no ANTHROPIC_API_KEY. Remove the old secret before redeploying:

```sh
wrangler secret delete ANTHROPIC_API_KEY --config factory/wrangler.jsonc
```

### Daemon protocol (for local Claude Code workers)

1. Poll GET /api/factory/queue?worker_id=YOUR_ID&limit=1
2. If slots.length > 0, take slots[0]
3. POST /api/factory/claim with {slot_id, worker_id}
4. Read slot.constitution_url and slot.diversity_instruction
5. Generate the component following the constitution exactly
6. Push a branch named feat/thought-{thought_id}-v{variant_index}
7. POST /api/factory/complete with result
8. Repeat from step 1

If shutting down before completing, POST /api/factory/release first.

---

## THE PREFERENCE MODEL LOOP

### What it is

Level 10 of the Voice Command Center ladder:

> Every PASS/FAIL/EDIT trains a preference model. After 100 signals, the factory
> stops shipping variants Ben would predictably FAIL. PASS rate climbs from 40%
> to 85% within a week.

The three files that implement this:

| File | Role |
|---|---|
| `preference-model.js` | Data layer. Records signals, computes bias hints. |
| `preference-prompt.md` | Locked template. Defines how hints are injected into Sonnet prompts. |
| `workers/thought-router.js` | Orchestrator. Calls buildBiasHints() and prepends the rendered block. |

### How the signal flows

1. Ben presses PASS or FAIL on a library card in the voice UI.
2. The UI calls `recordSignal(variant)` with the variant row from D1 (or localStorage stub in V1).
3. The signal is stored in the history window (last 100 per category).
4. The next time thought-router.js enqueues a generation job, it calls `buildBiasHints(category)`.
5. If at least 10 signals exist, the hints object is rendered into the preference block template (preference-prompt.md) and prepended to the Sonnet system prompt.
6. Sonnet sees the curator's historical preferences before generating. It biases toward what has been PASSed. It does not blindly copy. The constitution always overrides the hints.

### Data model

Each signal stored by recordSignal():

```json
{
  "id":            "variant-uuid",
  "category":      "scroll-beats",
  "diversity_mode": "bold",
  "pass_fail":     "pass",
  "css_patterns":  {
    "translateY":   "-24px",
    "ease":         "power2.out",
    "color_stops":  ["#635bff", "#4a9fff"]
  },
  "recorded_at":   1748649600000
}
```

### Bias hints object shape

buildBiasHints(category) returns:

```json
{
  "has_bias":           true,
  "signal_count":       84,
  "generated_at":       "2026-05-30T22:00:00Z",
  "category_filter":    "scroll-beats",
  "category_pass_rates": { "scroll-beats": { "pass": 42, "fail": 42, "total": 84, "pass_rate": 0.50 } },
  "slot_pass_rates": {
    "conservative":          { "pass": 10, "fail": 10, "total": 20, "pass_rate": 0.50 },
    "different_motion":      { "pass": 11, "fail": 9,  "total": 20, "pass_rate": 0.55 },
    "different_positioning": { "pass": 8,  "fail": 12, "total": 20, "pass_rate": 0.40 },
    "combination":           { "pass": 9,  "fail": 11, "total": 20, "pass_rate": 0.45 },
    "bold":                  { "pass": 15, "fail": 5,  "total": 20, "pass_rate": 0.75 }
  },
  "top_css_patterns": {
    "top_translate_y":  [{ "value": "-24px", "count": 14 }],
    "top_ease":         [{ "value": "power2.out", "count": 18 }],
    "top_color_stops":  [{ "value": "#635bff -> #4a9fff", "count": 11 }],
    "pass_sample_size": 53
  },
  "preferred_slots": ["bold"],
  "avoid_slots":     [],
  "summary":         "Preference model trained on 84 signals..."
}
```

### V1 storage: localStorage stub

V1 uses localStorage so the loop works in browser-only mode today (no Cloudflare Worker required). The storage adapter is swapped in a single place inside preference-model.js. When the factory ships with real D1 writes, that adapter is replaced and the rest of the module is unchanged.

The public API is stable: recordSignal(), buildBiasHints(), getHistory(), clearHistory(), seedStubHistory().

### Dev quickstart

```js
import { seedStubHistory, buildBiasHints, recordSignal } from './preference-model.js';

// Seed 80 synthetic signals so the model returns real-looking hints in demo
seedStubHistory();

// Get hints for the scroll-beats category
const hints = buildBiasHints('scroll-beats');
console.log(hints.summary);

// Record a new PASS signal
recordSignal({
  id: 'abc123',
  category: 'scroll-beats',
  diversity_mode: 'bold',
  curation: 'pass',
  component_metadata: JSON.stringify({ animation_technique: 'gsap-timeline' }),
  html_snippet: 'gsap.to(el, { translateY: -24, ease: "power2.out" })',
});
```

### Roadmap (follow-up sessions)

- Replace localStorage stub with D1 queries (last 100 PASS + last 100 FAIL per category via SQL window function).
- Add EDIT signal weighting: an EDIT is a soft FAIL but carries the voice transcript as a regeneration direction hint.
- Surface pass rate trend lines in the library UI (preference improving over time).
- Add cross-category pattern learning: a translateY of -24px that PASSes in scroll-beats should influence all categories, not just the one it was tagged to.
- Meta-worker (Level 13): analyze per-slot underperformance and propose prompt tuning automatically.
