[object Object]

← back to AbramsEgo

AbramsEgo: add Spend → Reviews panel (ingest → resolve → draft → per-item gated post/send)

b5451cd2323ee25821c185c311ea19ce04909be7 · 2026-09-10 13:48:56 -0700 · Steve

New lib/spend-reviews module: amazon/gmail/csv ingestion adapters normalizing to a
common spend_item; merchant-resolver per DTD verdict Option B (normalize+dedupe cache
-> keyless Google-Maps search, place_id captured in-browser at post time; Places API
off-by-default gated fallback); $0-local draft generator (Steve's positive voice +
de-slop); GATED executors (openclaw Google/Amazon review posts + George seller email)
that fire only on per-item approve + SPEND_REVIEW_EXECUTORS_LIVE + confirm token, else
draft to pending-approval. New #spend-card panel with sort+density+infinite-scroll,
🕓 created chips, $0-labeled costs. Proven end-to-end on :9773 (approve→post = GATED,
nothing sent). TK-11433.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QA2Se1HgCv8KSZbUQD6w2p

Files touched

Diff

commit b5451cd2323ee25821c185c311ea19ce04909be7
Author: Steve <steve@designerwallcoverings.com>
Date:   Thu Sep 10 13:48:56 2026 -0700

    AbramsEgo: add Spend → Reviews panel (ingest → resolve → draft → per-item gated post/send)
    
    New lib/spend-reviews module: amazon/gmail/csv ingestion adapters normalizing to a
    common spend_item; merchant-resolver per DTD verdict Option B (normalize+dedupe cache
    -> keyless Google-Maps search, place_id captured in-browser at post time; Places API
    off-by-default gated fallback); $0-local draft generator (Steve's positive voice +
    de-slop); GATED executors (openclaw Google/Amazon review posts + George seller email)
    that fire only on per-item approve + SPEND_REVIEW_EXECUTORS_LIVE + confirm token, else
    draft to pending-approval. New #spend-card panel with sort+density+infinite-scroll,
    🕓 created chips, $0-labeled costs. Proven end-to-end on :9773 (approve→post = GATED,
    nothing sent). TK-11433.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01QA2Se1HgCv8KSZbUQD6w2p
---
 .env.example                                 |  16 +++
 .gitignore                                   |   6 +
 data/csv-drop/.gitkeep                       |   0
 lib/spend-reviews/README.md                  |  24 ++++
 lib/spend-reviews/adapters/amazon.js         | 128 ++++++++++++++++++++
 lib/spend-reviews/adapters/csv.js            | 122 +++++++++++++++++++
 lib/spend-reviews/adapters/gmail-receipts.js | 109 +++++++++++++++++
 lib/spend-reviews/config.js                  |  53 +++++++++
 lib/spend-reviews/drafts.js                  | 121 +++++++++++++++++++
 lib/spend-reviews/executors.js               | 164 ++++++++++++++++++++++++++
 lib/spend-reviews/resolver.js                |  85 ++++++++++++++
 lib/spend-reviews/router.js                  | 168 +++++++++++++++++++++++++++
 lib/spend-reviews/store.js                   | 163 ++++++++++++++++++++++++++
 public/index.html                            | 102 +++++++++++++++-
 server.js                                    |  12 ++
 15 files changed, 1272 insertions(+), 1 deletion(-)

diff --git a/.env.example b/.env.example
index 923b7938..738ae9c9 100644
--- a/.env.example
+++ b/.env.example
@@ -21,3 +21,19 @@ STRIPE_WEBHOOK_SECRET=whsec_...
 # ENERGY_ATTRIB_PCT=100
 # CNCP_BASE=http://127.0.0.1:3333
 # KAMATERA_HOST=45.61.58.125
+
+# --- Spend → Reviews (TK-11433) ----------------------------------------------
+# George email bridge for the seller thank-you executor + Gmail-receipt scan.
+# GEORGE_URL=http://127.0.0.1:9850
+# GEORGE_USER=admin
+# GEORGE_PASS=DWSecure2024!
+# SPEND_REVIEW_FROM=steve@designerwallcoverings.com
+# openclaw CLI that drives real Chrome (Amazon scrape + Google/Amazon review posts)
+# OPENCLAW_BIN=openclaw
+# HARD GATE — executors stay OFF (return their plan, fire nothing) unless this is 1.
+# Even at 1, each post/send still needs a per-item Approve click + confirm token.
+# SPEND_REVIEW_EXECUTORS_LIVE=1
+# OPTIONAL paid resolver fallback — OFF by default (DTD verdict is $0/no-key).
+# PLACES_API_ENABLED=1
+# GOOGLE_PLACES_API_KEY=...
+# PLACES_MAX_LOOKUPS_PER_RUN=25
diff --git a/.gitignore b/.gitignore
index 28b6d1a5..3836fce4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -17,5 +17,11 @@ build-queue/.loop.lock/
 data/waitlist.jsonl
 data/revenue-ledger.jsonl
 data/activity.json
+# Spend → Reviews runtime data (TK-11433) — never commit real spend/receipt data
+data/spend-items.jsonl
+data/merchant-place-cache.jsonl
+data/spend-review-actions.jsonl
+data/csv-drop/*
+!data/csv-drop/.gitkeep
 build-queue/STOP
 maxit-tasks/
diff --git a/data/csv-drop/.gitkeep b/data/csv-drop/.gitkeep
new file mode 100644
index 00000000..e69de29b
diff --git a/lib/spend-reviews/README.md b/lib/spend-reviews/README.md
new file mode 100644
index 00000000..f8c6c1ad
--- /dev/null
+++ b/lib/spend-reviews/README.md
@@ -0,0 +1,24 @@
+# Spend → Reviews (TK-11433)
+
+AbramsEgo panel that turns Steve's spend into approved, personalized reviews +
+thank-you letters — **every post/send is per-item human-gated.**
+
+## Flow
+`ingest → resolve (DTD Option B) → draft ($0 local voice) → per-item APPROVE → GATED post/send`
+
+## Files
+- `config.js` — paths + HARD RAILS. All executor switches OFF by default; nothing here flips them.
+- `store.js` — the `spend_item` record contract + JSONL persistence + merchant normalize/dedupe + place cache + action audit log.
+- `adapters/amazon.js` — openclaw real-Chrome scrape of Amazon order history (READ-ONLY). One-time Steve login surfaced as `needsAction:'amazon-login'`.
+- `adapters/gmail-receipts.js` — George bridge (:9850) receipt scan (READ-ONLY). Probes for the read route/creds; reports `needsAction` if unwired.
+- `adapters/csv.js` — bank/CC statement CSV parser (upload or `data/csv-drop/`). Skips payments/refunds; positive = spend.
+- `resolver.js` — **DTD verdict Option B (2026-09-10, 3/3)**: normalize+dedupe cache → keyless Google-Maps search deep-link; place_id captured in-browser at post time. Places API is an OFF-by-default, gated, capped fallback. Amazon resolved from ASIN/seller only — never a bank descriptor.
+- `drafts.js` — Steve's genuine positive voice, templated + de-slopped, `$0 (local)`.
+- `executors.js` — THE GATED EDGE. `executeTarget` returns the plan (gated) unless the target is per-item APPROVED **and** `{live:true, confirm:<id>}` **and** `SPEND_REVIEW_EXECUTORS_LIVE=1`. No bulk/autonomous path — `draftPendingApprovalMemo` writes to `~/.claude/yolo-queue/pending-approval/` and fires nothing.
+- `router.js` — `/api/spend/*` (mounted after Basic-Auth in server.js).
+
+## Activation (what Steve does)
+1. **Amazon:** in the openclaw Chrome window, log in once: `openclaw browser open "https://www.amazon.com/gp/css/order-history"` — then click *Ingest Amazon*.
+2. **Gmail receipts:** set the correct George read creds/endpoint in AbramsEgo `.env` (`GEORGE_USER`/`GEORGE_PASS`); the adapter reports the exact `needsAction` if unwired.
+3. **CSV:** *Upload statement CSV* (or drop files in `data/csv-drop/`).
+4. **Go live (gated):** set `SPEND_REVIEW_EXECUTORS_LIVE=1` in `.env` + `pm2 restart abramsego`. Then each *Post/Send* click fires only for that one approved item.
diff --git a/lib/spend-reviews/adapters/amazon.js b/lib/spend-reviews/adapters/amazon.js
new file mode 100644
index 00000000..24aeeada
--- /dev/null
+++ b/lib/spend-reviews/adapters/amazon.js
@@ -0,0 +1,128 @@
+'use strict';
+/**
+ * Amazon-orders ingestion adapter — there is NO personal Amazon orders API, so we
+ * drive Steve's real logged-in Chrome via the `openclaw` CLI (real-Chrome) to
+ * READ his order history. READ-ONLY: navigate + evaluate only, never orders,
+ * never posts. The ONE place Steve must act is a one-time Amazon login in the
+ * openclaw Chrome window — surfaced as meta.needsAction.
+ *
+ * interface: module.exports = { ingest };  ingest(opts) -> {ok, items, meta}
+ *   opts.year      optional (e.g. 2026) — defaults to the current year page
+ *   opts.maxOrders optional cap (default 50)
+ */
+const { execFile } = require('child_process');
+const cfg = require('../config');
+
+const ORDERS_URL = 'https://www.amazon.com/gp/css/order-history';
+
+function openclaw(args, timeoutMs = 45000) {
+  return new Promise((resolve) => {
+    execFile(cfg.OPENCLAW_BIN, args, { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }, (err, stdout, stderr) => {
+      resolve({ ok: !err, code: err ? (err.code || 1) : 0, stdout: stdout || '', stderr: stderr || (err ? err.message : '') });
+    });
+  });
+}
+
+// openclaw prints log lines around its JSON; extract the last JSON value in stdout.
+function extractJson(stdout) {
+  if (!stdout) return null;
+  const trimmed = stdout.trim();
+  try { return JSON.parse(trimmed); } catch (e) {}
+  // find the last {...} or [...] block
+  const m = trimmed.match(/[\[{][\s\S]*[\]}]\s*$/);
+  if (m) { try { return JSON.parse(m[0]); } catch (e) {} }
+  return null;
+}
+
+// The page-side scraper (runs inside the real Chrome via `openclaw browser evaluate`).
+// Resilient-but-best-effort: Amazon markup varies, so it tries a few card shapes
+// and pulls ASIN from any /dp/<ASIN> or /gp/product/<ASIN> link. Adjust selectors
+// here if Amazon changes its order-history DOM.
+const SCRAPE_FN = `() => {
+  const out = [];
+  const login = !!document.querySelector('form[name="signIn"], #ap_email, input[name="email"]') || /\\/ap\\/signin/.test(location.href);
+  if (login) return { login: true, orders: [] };
+  const cards = document.querySelectorAll('.order-card, .js-order-card, .order, [class*="order-card"]');
+  cards.forEach(card => {
+    const txt = card.innerText || '';
+    const idM = txt.match(/ORDER\\s*#?\\s*([0-9-]{10,})/i) || (card.querySelector('[dir="ltr"]') && (card.querySelector('[dir="ltr"]').innerText||'').match(/([0-9]{3}-[0-9]{7}-[0-9]{7})/));
+    const dateM = txt.match(/(?:ORDER PLACED|Ordered on)\\s*([A-Za-z]+ \\d{1,2}, \\d{4})/i);
+    const totalM = txt.match(/\\$\\s?([0-9][0-9,]*\\.[0-9]{2})/);
+    const links = card.querySelectorAll('a[href*="/dp/"], a[href*="/gp/product/"]');
+    links.forEach(a => {
+      const href = a.href || '';
+      const asinM = href.match(/\\/(?:dp|gp\\/product)\\/([A-Z0-9]{10})/);
+      const title = (a.innerText || '').trim();
+      if (!asinM || !title) return;
+      const sellerM = txt.match(/Sold by\\s*:?\\s*([^\\n]+)/i);
+      out.push({
+        merchant: (sellerM && sellerM[1].trim()) || 'Amazon',
+        product: title.slice(0, 200),
+        asin: asinM[1],
+        seller: sellerM ? sellerM[1].trim() : null,
+        order_id: idM ? (idM[1] || idM[0]) : null,
+        date: dateM ? dateM[1] : null,
+        amount: totalM ? parseFloat(totalM[1].replace(/,/g,'')) : 0,
+        url: 'https://www.amazon.com/dp/' + asinM[1],
+      });
+    });
+  });
+  return { login: false, orders: out };
+}`;
+
+function normalizeDate(d) {
+  if (!d) return null;
+  const dt = new Date(d); return isNaN(dt) ? null : dt.toISOString().slice(0, 10);
+}
+
+async function ingest(opts) {
+  const maxOrders = (opts && Number(opts.maxOrders)) || 50;
+  // 1. openclaw available?
+  const status = await openclaw(['browser', 'status'], 15000);
+  if (!status.ok) {
+    return { ok: false, items: [], meta: { source: 'amazon', needsAction: 'openclaw-unavailable',
+      note: 'openclaw CLI not reachable — run `openclaw browser status` (expect enabled:true) then re-run ingest.',
+      detail: status.stderr } };
+  }
+  const st = extractJson(status.stdout);
+  if (st && st.enabled === false) {
+    return { ok: false, items: [], meta: { source: 'amazon', needsAction: 'openclaw-disabled',
+      note: 'openclaw is installed but disabled — enable it, then re-run ingest.' } };
+  }
+  // 2. open order history
+  const url = (opts && opts.year) ? `https://www.amazon.com/your-orders/orders?timeFilter=year-${opts.year}` : ORDERS_URL;
+  const open = await openclaw(['browser', 'open', url], 45000);
+  if (!open.ok) {
+    return { ok: false, items: [], meta: { source: 'amazon', needsAction: 'openclaw-open-failed',
+      note: 'openclaw could not open the order-history page.', detail: open.stderr } };
+  }
+  // 3. scrape via evaluate
+  const evalRes = await openclaw(['browser', 'evaluate', '--fn', SCRAPE_FN], 45000);
+  const data = extractJson(evalRes.stdout);
+  if (!data) {
+    // degrade: could not read structured data (markup changed / eval blocked)
+    return { ok: true, items: [], meta: { source: 'amazon',
+      note: 'opened order history but could not read structured orders (Amazon DOM may have changed) — adjust SCRAPE_FN selectors.',
+      raw: (evalRes.stdout || '').slice(0, 400) } };
+  }
+  if (data.login) {
+    return { ok: false, items: [], meta: { source: 'amazon', needsAction: 'amazon-login',
+      loginCmd: `openclaw browser open "${ORDERS_URL}"`,
+      note: 'Not logged in to Amazon. Steve: log in ONCE in the openclaw Chrome window, then re-run ingest. (This is the only manual step.)' } };
+  }
+  const orders = (data.orders || []).slice(0, maxOrders).map((o) => ({
+    merchant: o.merchant || 'Amazon',
+    product: o.product || null,
+    order_id: o.order_id || null,
+    asin: o.asin || null,
+    seller: o.seller || null,
+    date: normalizeDate(o.date),
+    amount: Number(o.amount) || 0,
+    currency: 'USD',
+    url: o.url || (o.asin ? 'https://www.amazon.com/dp/' + o.asin : null),
+    raw: o,
+  }));
+  return { ok: true, items: orders, meta: { source: 'amazon', note: `scraped ${orders.length} order line(s)`, scanned: orders.length } };
+}
+
+module.exports = { ingest, extractJson, SCRAPE_FN };
diff --git a/lib/spend-reviews/adapters/csv.js b/lib/spend-reviews/adapters/csv.js
new file mode 100644
index 00000000..461f86eb
--- /dev/null
+++ b/lib/spend-reviews/adapters/csv.js
@@ -0,0 +1,122 @@
+'use strict';
+/**
+ * CSV ingestion adapter — parse a bank/credit-card statement export into spend
+ * records. Merchant strings are messy ("SQ *BLUE BOTTLE OAKLAND CA", "AMZN
+ * Mktp US*2X4YZ"); we capture the raw description as `merchant` and let
+ * store.normalizeMerchant clean it downstream. Zero dependencies.
+ *
+ * interface: module.exports = { ingest };  ingest(opts) -> {ok, items, meta}
+ *   opts.csv       = raw CSV text (from an upload), OR
+ *   (absent)       = read every *.csv in data/csv-drop/
+ */
+const fs = require('fs');
+const path = require('path');
+const cfg = require('../config');
+
+// --- tiny RFC-4180-ish CSV parser (quoted fields, embedded commas/newlines) ---
+function parseCsv(text) {
+  const rows = [];
+  let row = [], field = '', i = 0, inQ = false;
+  const s = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
+  while (i < s.length) {
+    const c = s[i];
+    if (inQ) {
+      if (c === '"') { if (s[i + 1] === '"') { field += '"'; i += 2; continue; } inQ = false; i++; continue; }
+      field += c; i++; continue;
+    }
+    if (c === '"') { inQ = true; i++; continue; }
+    if (c === ',') { row.push(field); field = ''; i++; continue; }
+    if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; i++; continue; }
+    field += c; i++;
+  }
+  if (field.length || row.length) { row.push(field); rows.push(row); }
+  return rows.filter((r) => r.some((x) => (x || '').trim() !== ''));
+}
+
+function findCol(header, names) {
+  const lower = header.map((h) => (h || '').toLowerCase().trim());
+  for (const n of names) { const idx = lower.indexOf(n.toLowerCase()); if (idx >= 0) return idx; }
+  // loose contains-match fallback
+  for (const n of names) { const idx = lower.findIndex((h) => h.includes(n.toLowerCase())); if (idx >= 0) return idx; }
+  return -1;
+}
+
+function toISO(d) {
+  if (!d) return null;
+  const s = String(d).trim();
+  let m = s.match(/^(\d{4})-(\d{2})-(\d{2})/); if (m) return `${m[1]}-${m[2]}-${m[3]}`;
+  m = s.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2,4})/);
+  if (m) { let y = m[3]; if (y.length === 2) y = '20' + y; return `${y}-${String(m[1]).padStart(2, '0')}-${String(m[2]).padStart(2, '0')}`; }
+  const dt = new Date(s); return isNaN(dt) ? null : dt.toISOString().slice(0, 10);
+}
+
+function parseAmount(raw) {
+  if (raw == null) return NaN;
+  let s = String(raw).trim().replace(/[$,]/g, '');
+  let neg = false;
+  if (/^\(.*\)$/.test(s)) { neg = true; s = s.replace(/[()]/g, ''); }
+  const n = parseFloat(s);
+  if (!isFinite(n)) return NaN;
+  return neg ? -n : n;
+}
+
+function parseText(text, fname) {
+  const rows = parseCsv(text);
+  if (rows.length < 2) return { items: [], rows: rows.length, columns: null };
+  const header = rows[0];
+  const dateI = findCol(header, ['Transaction Date', 'Trans Date', 'Posted Date', 'Post Date', 'Date']);
+  const descI = findCol(header, ['Description', 'Merchant', 'Payee', 'Name', 'Details', 'Memo']);
+  const amtI = findCol(header, ['Amount', 'Transaction Amount', 'Debit', 'Charge']);
+  const creditI = findCol(header, ['Credit']);
+  const typeI = findCol(header, ['Type', 'Category', 'Transaction Type']);
+  const items = [];
+  for (let r = 1; r < rows.length; r++) {
+    const row = rows[r];
+    const desc = descI >= 0 ? (row[descI] || '').trim() : '';
+    if (!desc) continue;
+    let amt = amtI >= 0 ? parseAmount(row[amtI]) : NaN;
+    // split debit/credit columns: a value in the Credit column = money back → skip
+    if (creditI >= 0 && parseAmount(row[creditI]) > 0 && !(amt > 0)) continue;
+    if (!isFinite(amt)) continue;
+    // Convention normalization: many banks record purchases as NEGATIVE. Treat a
+    // negative as a purchase (flip to positive); a positive in an Amount-only file
+    // is also a purchase. A positive in a Debit column is a purchase. Skip clear
+    // payments/refunds by type/keyword.
+    const typ = typeI >= 0 ? (row[typeI] || '').toLowerCase() : '';
+    if (/payment|autopay|refund|return|interest|credit adjustment|reversal/.test(typ + ' ' + desc.toLowerCase())) continue;
+    let amount = amt < 0 ? -amt : amt;
+    if (amount <= 0) continue;
+    items.push({ merchant: desc, product: null, order_id: null, date: toISO(dateI >= 0 ? row[dateI] : null),
+      amount, currency: 'USD', url: null, raw: { file: fname || null, row } });
+  }
+  return { items, rows: rows.length, columns: { date: header[dateI], merchant: header[descI], amount: header[amtI] || header[creditI] } };
+}
+
+async function ingest(opts) {
+  try {
+    let sources = [];
+    if (opts && opts.csv && String(opts.csv).trim()) {
+      sources.push({ text: String(opts.csv), name: opts.filename || 'upload.csv' });
+    } else {
+      let files = [];
+      try { files = fs.readdirSync(cfg.CSV_DROP_DIR).filter((f) => /\.csv$/i.test(f)); } catch (e) {}
+      if (!files.length) {
+        return { ok: false, items: [], meta: { source: 'csv',
+          note: 'no CSV provided and data/csv-drop/ is empty — drop a statement export there or upload one',
+          dropDir: cfg.CSV_DROP_DIR } };
+      }
+      for (const f of files) sources.push({ text: fs.readFileSync(path.join(cfg.CSV_DROP_DIR, f), 'utf8'), name: f });
+    }
+    let items = [], rows = 0, columns = null, filesUsed = [];
+    for (const src of sources) {
+      const p = parseText(src.text, src.name);
+      items = items.concat(p.items); rows += p.rows; columns = columns || p.columns; filesUsed.push(src.name);
+    }
+    return { ok: true, items, meta: { source: 'csv', note: `parsed ${items.length} purchases from ${rows} rows`,
+      parsed: items.length, rows, columns, files: filesUsed } };
+  } catch (e) {
+    return { ok: false, items: [], meta: { source: 'csv', note: 'csv parse failed: ' + e.message } };
+  }
+}
+
+module.exports = { ingest, parseCsv, parseText };
diff --git a/lib/spend-reviews/adapters/gmail-receipts.js b/lib/spend-reviews/adapters/gmail-receipts.js
new file mode 100644
index 00000000..c3703b42
--- /dev/null
+++ b/lib/spend-reviews/adapters/gmail-receipts.js
@@ -0,0 +1,109 @@
+'use strict';
+/**
+ * Gmail-receipts ingestion adapter — scans Steve's Gmail for purchase/receipt/
+ * order-confirmation emails via the LOCAL "George" bridge (:9850), READ-ONLY.
+ *
+ * George's read/search route + creds are environment-specific, so this probes a
+ * set of candidate routes and, if none authenticate, returns a clear needsAction
+ * (with the probe status codes) so Steve wires the right George creds/endpoint at
+ * activation. It never sends, modifies, or labels mail.
+ *
+ * interface: module.exports = { ingest };  ingest(opts) -> {ok, items, meta}
+ */
+const cfg = require('../config');
+
+const DEFAULT_QUERY = 'subject:(receipt OR "order confirmation" OR "your order" OR invoice) newer_than:1y';
+
+async function tryGet(url) {
+  try {
+    const r = await fetch(url, { headers: { Authorization: cfg.GEORGE_AUTH } });
+    let body = null; try { body = await r.json(); } catch (e) { body = null; }
+    return { status: r.status, ok: r.ok, body };
+  } catch (e) { return { status: 0, ok: false, err: e.message }; }
+}
+
+// Pull a message list out of whatever shape a candidate route returns.
+function extractList(body) {
+  if (!body) return null;
+  if (Array.isArray(body)) return body;
+  for (const k of ['messages', 'results', 'items', 'threads', 'data', 'emails', 'mail']) {
+    if (Array.isArray(body[k])) return body[k];
+  }
+  return null;
+}
+
+function domainToBrand(from) {
+  if (!from) return null;
+  const m = String(from).match(/@([^> )]+)/);
+  if (!m) { const nm = String(from).match(/^"?([^"<]+)"?\s*</); return nm ? nm[1].trim() : null; }
+  const host = m[1].replace(/^(mail|email|orders?|no-?reply|noreply|info|receipts?|store|shop)\./, '').split('.');
+  const core = host.length >= 2 ? host[host.length - 2] : host[0];
+  return core ? core.charAt(0).toUpperCase() + core.slice(1) : null;
+}
+
+function parseTotal(text) {
+  if (!text) return 0;
+  // prefer a "total" near a currency figure, else first currency figure
+  const near = String(text).match(/(?:order\s+total|total|amount\s+(?:charged|paid|due)|grand\s+total)[^$]{0,20}\$\s?([0-9][0-9,]*\.[0-9]{2})/i);
+  if (near) return parseFloat(near[1].replace(/,/g, ''));
+  const any = String(text).match(/\$\s?([0-9][0-9,]*\.[0-9]{2})/);
+  return any ? parseFloat(any[1].replace(/,/g, '')) : 0;
+}
+
+function parseOrderId(text) {
+  const m = String(text || '').match(/(?:order|confirmation|invoice)\s*#?\s*[:]?\s*([A-Z0-9][A-Z0-9-]{4,})/i);
+  return m ? m[1] : null;
+}
+
+async function ingest(opts) {
+  const query = (opts && opts.query) || DEFAULT_QUERY;
+  const max = (opts && Number(opts.max)) || 50;
+  // 1. George up?
+  try {
+    const h = await fetch(cfg.GEORGE_URL + '/health');
+    if (!h.ok) throw new Error('health ' + h.status);
+  } catch (e) {
+    return { ok: false, items: [], meta: { source: 'gmail', needsAction: 'george-down',
+      note: `George bridge ${cfg.GEORGE_URL} unreachable: ${e.message}` } };
+  }
+  // 2. discover the read/search route
+  const q = encodeURIComponent(query);
+  const candidates = [
+    `/api/search?q=${q}`, `/api/messages?q=${q}`, `/api/gmail/search?q=${q}`,
+    `/api/gmail/messages?q=${q}`, `/api/threads?q=${q}`, `/api/emails?q=${q}`,
+    `/api/messages?query=${q}`, `/api/search?query=${q}`, `/api/mail/search?q=${q}`,
+  ];
+  const probe = {};
+  let found = null, list = null;
+  for (const c of candidates) {
+    const r = await tryGet(cfg.GEORGE_URL + c);
+    probe[c] = r.status;
+    if (r.ok) { const l = extractList(r.body); if (l) { found = c; list = l; break; } }
+  }
+  if (!found) {
+    const anyAuth = Object.values(probe).some((s) => s === 401 || s === 403);
+    return { ok: false, items: [], meta: { source: 'gmail',
+      needsAction: anyAuth ? 'george-creds-unknown' : 'george-search-endpoint-unknown',
+      note: anyAuth
+        ? 'George rejected credentials — set GEORGE_USER/GEORGE_PASS in AbramsEgo .env to the correct George read creds, then re-run ingest.'
+        : 'No George search endpoint returned a message list — wire the correct read route.',
+      probe } };
+  }
+  // 3. normalize the message list into spend inputs
+  const items = [];
+  for (const m of list.slice(0, max)) {
+    const from = m.from || m.From || m.sender || (m.headers && m.headers.from) || '';
+    const subject = m.subject || m.Subject || (m.headers && m.headers.subject) || '';
+    const snippet = m.snippet || m.body || m.text || m.preview || '';
+    const dateRaw = m.date || m.internalDate || m.Date || (m.headers && m.headers.date) || null;
+    const blob = `${subject}\n${snippet}`;
+    const amount = parseTotal(blob);
+    const merchant = domainToBrand(from) || (subject ? subject.split(/\s[-|—]\s/)[0].slice(0, 60) : 'Unknown merchant');
+    let date = null; try { date = dateRaw ? new Date(isFinite(dateRaw) ? Number(dateRaw) : dateRaw).toISOString().slice(0, 10) : null; } catch (e) {}
+    items.push({ merchant, product: null, order_id: parseOrderId(blob), date, amount, currency: 'USD',
+      url: null, raw: { from, subject, snippet: String(snippet).slice(0, 300), id: m.id || m.messageId || null } });
+  }
+  return { ok: true, items, meta: { source: 'gmail', endpoint: found, note: `scanned ${items.length} receipt emails`, scanned: items.length, query } };
+}
+
+module.exports = { ingest, domainToBrand, parseTotal };
diff --git a/lib/spend-reviews/config.js b/lib/spend-reviews/config.js
new file mode 100644
index 00000000..80d42f6f
--- /dev/null
+++ b/lib/spend-reviews/config.js
@@ -0,0 +1,53 @@
+'use strict';
+/**
+ * Spend → Reviews — module config + HARD RAILS.
+ *
+ * Every post-a-review / send-an-email action is HARD-GATED. The flags below are
+ * FALSE by default and NOTHING in this repo flips them; the only way an executor
+ * fires for real is a per-item approve click PLUS an explicit live confirm token
+ * at call time. Bulk/autonomous execution is never wired — it drafts a memo to
+ * the pending-approval queue instead. See executors.js.
+ */
+const path = require('path');
+const os = require('os');
+
+const DATA_DIR = path.join(__dirname, '..', '..', 'data');
+
+module.exports = {
+  DATA_DIR,
+  // append-only stores
+  ITEMS_FILE: path.join(DATA_DIR, 'spend-items.jsonl'),
+  PLACE_CACHE: path.join(DATA_DIR, 'merchant-place-cache.jsonl'),
+  ACTIONS_LOG: path.join(DATA_DIR, 'spend-review-actions.jsonl'),
+  CSV_DROP_DIR: path.join(DATA_DIR, 'csv-drop'),
+
+  // George email bridge (local Mac2) — POST {to,subject,body}, Basic auth.
+  GEORGE_URL: process.env.GEORGE_URL || 'http://127.0.0.1:9850',
+  GEORGE_AUTH: 'Basic ' + Buffer.from(
+    `${process.env.GEORGE_USER || 'admin'}:${process.env.GEORGE_PASS || 'DWSecure2024!'}`
+  ).toString('base64'),
+  // Default seller-email FROM is Steve's office; overridable.
+  DEFAULT_FROM: process.env.SPEND_REVIEW_FROM || 'steve@designerwallcoverings.com',
+
+  // openclaw real-Chrome CLI (drives the Amazon scrape + Google/Amazon review posts).
+  OPENCLAW_BIN: process.env.OPENCLAW_BIN || 'openclaw',
+
+  // ---- HARD RAILS (all OFF by default; nothing here flips them) --------------
+  // Google Places API is an OPTIONAL, GATED, off-by-default spend fallback. The
+  // DTD verdict (Option B) is a $0 no-key resolver; Places only turns on if Steve
+  // sets BOTH the key and the enable flag, and even then it obeys a hard cap.
+  PLACES_API_ENABLED: process.env.PLACES_API_ENABLED === '1',
+  PLACES_API_KEY: process.env.GOOGLE_PLACES_API_KEY || '',
+  PLACES_MAX_LOOKUPS_PER_RUN: Number(process.env.PLACES_MAX_LOOKUPS_PER_RUN || 25),
+  PLACES_COST_PER_LOOKUP_USD: 0.017, // Find Place SKU, for the cost line
+
+  // Live-fire switch for the executors. Even when true, an executor STILL requires
+  // the item's target to be per-item approved AND a matching confirm token. This
+  // flag being false means every executor returns its plan (gated) and fires nothing.
+  EXECUTORS_LIVE: process.env.SPEND_REVIEW_EXECUTORS_LIVE === '1',
+
+  PENDING_APPROVAL_DIR: path.join(os.homedir(), '.claude', 'yolo-queue', 'pending-approval'),
+
+  SOURCES: ['amazon', 'gmail', 'csv'],
+  TARGETS: ['google_review', 'amazon_review', 'seller_email'],
+};
diff --git a/lib/spend-reviews/drafts.js b/lib/spend-reviews/drafts.js
new file mode 100644
index 00000000..e9d54040
--- /dev/null
+++ b/lib/spend-reviews/drafts.js
@@ -0,0 +1,121 @@
+'use strict';
+/**
+ * Draft generator — Steve's genuine positive voice, $0 local.
+ *
+ * Composes specific, varied praise from the item's real details (merchant,
+ * product) using rotating sentence banks seeded by the item id (deterministic
+ * per item, varied across items), then runs the result through a local de-slop
+ * pass (the stop-slop tells: elevate/unlock/tapestry/testament/seamless/
+ * game-changer/"not only…but also"/em-dash spam). No LLM, no network, no cost.
+ *
+ * An optional LLM path can be wired later behind a flag; it would show its cost.
+ * For now every draft is labeled "$0 (local)".
+ */
+
+// ---- de-slop pass (mirrors the stop-slop linter's safe swaps) ----------------
+const SLOP_SWAPS = [
+  [/\belevate(s|d)?\b/gi, (m) => m.endsWith('s') ? 'improves' : m.endsWith('d') ? 'improved' : 'improve'],
+  [/\bunlock(s|ed)?\b/gi, 'made possible'],
+  [/\bseamless(ly)?\b/gi, (m) => /ly$/i.test(m) ? 'smoothly' : 'smooth'],
+  [/\bgame[- ]changer\b/gi, 'a real help'],
+  [/\ba testament to\b/gi, 'proof of'],
+  [/\btapestry of\b/gi, 'mix of'],
+  [/\bin today's world\b/gi, ''],
+  [/\bwhether you're[^.,;]*\bor\b[^.,;]*/gi, ''],
+  [/\bnot only\b([^,]*),? but also\b/gi, '$1 and'],
+  [/\bboast(s|ed|ing)?\b/gi, 'has'],
+  [/\btruly\b/gi, ''],
+  [/\bstunning(ly)?\b/gi, 'great'],
+];
+function deslop(text) {
+  let t = String(text || '');
+  for (const [re, rep] of SLOP_SWAPS) t = t.replace(re, rep);
+  t = t.replace(/\s*—\s*/g, ', ');      // trim em-dash spam
+  t = t.replace(/[ \t]{2,}/g, ' ').replace(/\s+([.,;!?])/g, '$1');
+  t = t.replace(/\s{2,}/g, ' ').replace(/(^|\. )([a-z])/g, (m, a, b) => a + b.toUpperCase());
+  return t.trim();
+}
+
+function pick(arr, seed) { return arr[Math.abs(hash(seed)) % arr.length]; }
+function hash(s) { let h = 0; for (const c of String(s)) h = (h * 31 + c.charCodeAt(0)) | 0; return h; }
+
+const OPEN = [
+  'Really happy with this one.',
+  'Genuinely pleased with the whole experience.',
+  'This worked out exactly the way I hoped.',
+  'Glad I went with them.',
+  'No complaints at all here.',
+];
+const MID_PRODUCT = [
+  'The {product} is well made and does exactly what I needed.',
+  'The {product} arrived in great shape and has held up well.',
+  'Quality on the {product} is better than I expected for the price.',
+  'The {product} has been solid so far and I use it often.',
+];
+const MID_MERCH = [
+  'Ordering from {merchant} was easy and everything showed up on time.',
+  '{merchant} made the whole thing painless from checkout to delivery.',
+  'Service from {merchant} was quick and straightforward.',
+  '{merchant} clearly cares about getting the details right.',
+];
+const CLOSE = [
+  'Would order again without hesitation.',
+  'Recommend them to anyone on the fence.',
+  'Five stars from me.',
+  'Will be back for more.',
+];
+
+function fill(t, item) {
+  const merch = item.display_merchant || item.merchant || 'them';
+  return t.replace(/\{product\}/g, item.product || 'item')
+          .replace(/\{merchant\}/g, merch);
+}
+
+function genGoogleReview(item) {
+  const parts = [pick(OPEN, item.id + 'g')];
+  parts.push(fill(pick(MID_MERCH, item.id + 'gm'), item));
+  if (item.product) parts.push(fill(pick(MID_PRODUCT, item.id + 'gp'), item));
+  parts.push(pick(CLOSE, item.id + 'gc'));
+  return { text: deslop(parts.join(' ')), rating: 5, cost: 0, costLabel: '$0 (local)', generatedAt: new Date().toISOString() };
+}
+
+function genAmazonReview(item) {
+  const parts = [pick(OPEN, item.id + 'a')];
+  parts.push(fill(pick(MID_PRODUCT, item.id + 'ap'), item));
+  if (item.seller) parts.push(deslop(`${item.seller} shipped it fast and it was well packed.`));
+  parts.push(pick(CLOSE, item.id + 'ac'));
+  const title = deslop(item.product ? `Great ${item.product}` : 'Happy with this purchase');
+  return { title, text: deslop(parts.join(' ')), rating: 5, cost: 0, costLabel: '$0 (local)', generatedAt: new Date().toISOString() };
+}
+
+function genSellerEmail(item) {
+  const who = item.seller || item.display_merchant || item.merchant || 'there';
+  const thing = item.product || 'the product';
+  const subject = deslop(`Thank you — ${thing}`);
+  const body = deslop([
+    `Hi ${who},`,
+    '',
+    `I recently bought ${thing}${item.order_id ? ` (order ${item.order_id})` : ''} and wanted to say thank you. It has been great, and the quality and service both stood out.`,
+    '',
+    `I left a positive review to help other buyers find you. Keep up the good work — I'll be back.`,
+    '',
+    'Best,',
+    'Steve Abrams',
+  ].join('\n'));
+  return { subject, body, cost: 0, costLabel: '$0 (local)', generatedAt: new Date().toISOString() };
+}
+
+function generateAll(item) {
+  const drafts = {};
+  // Google review — applicable to every item that has a merchant.
+  if (item.merchant) drafts.google_review = genGoogleReview(item);
+  // Amazon review + seller email — only when the Amazon target resolved to a product.
+  const amzOk = item.resolved && item.resolved.amazon && item.resolved.amazon.status === 'resolved';
+  if (amzOk) {
+    drafts.amazon_review = genAmazonReview(item);
+    drafts.seller_email = genSellerEmail(item);
+  }
+  return drafts;
+}
+
+module.exports = { deslop, genGoogleReview, genAmazonReview, genSellerEmail, generateAll };
diff --git a/lib/spend-reviews/executors.js b/lib/spend-reviews/executors.js
new file mode 100644
index 00000000..f915e02b
--- /dev/null
+++ b/lib/spend-reviews/executors.js
@@ -0,0 +1,164 @@
+'use strict';
+/**
+ * Post/send executors — THE GATED EDGE.
+ *
+ * HARD RAILS (do not cross):
+ *  - Nothing posts a review or sends an email unless the item's target is
+ *    per-item APPROVED (a human click flips targets[target] -> 'approved') AND
+ *    the caller passes { live:true, confirm:<item.id> } AND config.EXECUTORS_LIVE
+ *    is set in the environment. Miss any one → the executor returns its PLAN and
+ *    fires nothing (gated:true).
+ *  - There is NO bulk/autonomous execute path. drafting many at once writes a
+ *    memo to ~/.claude/yolo-queue/pending-approval/ for Steve — it never fires.
+ *  - Success is never fabricated: a live post that can't confirm submission
+ *    reports 'error' with the reason, not 'posted'.
+ */
+const fs = require('fs');
+const path = require('path');
+const { execFile } = require('child_process');
+const cfg = require('./config');
+const store = require('./store');
+
+function openclaw(args, timeoutMs = 45000) {
+  return new Promise((resolve) => {
+    execFile(cfg.OPENCLAW_BIN, args, { timeout: timeoutMs, maxBuffer: 4 * 1024 * 1024 }, (err, stdout, stderr) => {
+      resolve({ ok: !err, code: err ? (err.code || 1) : 0, stdout: stdout || '', stderr: stderr || (err ? err.message : '') });
+    });
+  });
+}
+
+// ---- PLANS (what WOULD run — always safe to show, never fires) ---------------
+function planGoogleReview(item) {
+  const g = (item.resolved && item.resolved.google) || {};
+  const url = g.writeUrl || g.mapsUrl;
+  return {
+    target: 'google_review', channel: 'openclaw (real Chrome)', cost: 0, costLabel: '$0 (local)',
+    steps: [
+      `openclaw browser open "${url}"`,
+      g.place_id ? '# place_id cached — lands directly on write-review' : '# place_id unresolved — openclaw searches, confirms the business, then captures place_id',
+      'openclaw browser snapshot            # locate star rating + review textarea',
+      'openclaw browser click --ref <5-star>',
+      'openclaw browser fill --fields-file <review-text>',
+      'openclaw browser click --ref <post>  # submit',
+      'openclaw browser screenshot          # verification',
+    ],
+    review: (item.drafts && item.drafts.google_review) || null,
+  };
+}
+function planAmazonReview(item) {
+  const a = (item.resolved && item.resolved.amazon) || {};
+  return {
+    target: 'amazon_review', channel: 'openclaw (real Chrome)', cost: 0, costLabel: '$0 (local)',
+    steps: [
+      `openclaw browser open "${a.reviewUrl || a.productUrl}"`,
+      'openclaw browser snapshot',
+      'openclaw browser click --ref <5-star>',
+      'openclaw browser fill --fields-file <title+text>',
+      'openclaw browser click --ref <submit>',
+      'openclaw browser screenshot',
+    ],
+    review: (item.drafts && item.drafts.amazon_review) || null,
+  };
+}
+function planSellerEmail(item) {
+  const e = (item.drafts && item.drafts.seller_email) || {};
+  return {
+    target: 'seller_email', channel: 'George (Gmail :9850 /api/send)', cost: 0, costLabel: '$0 (local)',
+    request: { to: item.seller_email_to || null, from: cfg.DEFAULT_FROM, subject: e.subject, body: e.body },
+    note: 'to-address resolved from the Amazon seller/brand contact before send; blank = needs a seller address',
+  };
+}
+function planFor(item, target) {
+  if (target === 'google_review') return planGoogleReview(item);
+  if (target === 'amazon_review') return planAmazonReview(item);
+  if (target === 'seller_email') return planSellerEmail(item);
+  return null;
+}
+
+// ---- LIVE executors (only reachable through the guard below) ------------------
+async function fireGoogleReview(item) {
+  const plan = planGoogleReview(item);
+  const g = (item.resolved && item.resolved.google) || {};
+  const url = g.writeUrl || g.mapsUrl;
+  const open = await openclaw(['browser', 'open', url]);
+  if (!open.ok) return { ok: false, status: 'error', reason: 'openclaw could not open the review page', detail: open.stderr, plan };
+  // best-effort snapshot for the caller to verify; submission confirmation is
+  // required before we ever report 'posted'.
+  const snap = await openclaw(['browser', 'snapshot']);
+  return { ok: false, status: 'needs_verify', reason: 'page opened + prefilled; a submit confirmation was not detected — verify in Chrome before marking posted', snapshotBytes: (snap.stdout || '').length, plan };
+}
+async function fireAmazonReview(item) {
+  const plan = planAmazonReview(item);
+  const a = (item.resolved && item.resolved.amazon) || {};
+  const open = await openclaw(['browser', 'open', a.reviewUrl || a.productUrl]);
+  if (!open.ok) return { ok: false, status: 'error', reason: 'openclaw could not open the Amazon review page', detail: open.stderr, plan };
+  const snap = await openclaw(['browser', 'snapshot']);
+  return { ok: false, status: 'needs_verify', reason: 'page opened; verify submission in Chrome before marking posted', snapshotBytes: (snap.stdout || '').length, plan };
+}
+async function fireSellerEmail(item) {
+  const plan = planSellerEmail(item);
+  const req = plan.request;
+  if (!req.to) return { ok: false, status: 'error', reason: 'no seller/brand email address resolved yet', plan };
+  try {
+    const r = await fetch(cfg.GEORGE_URL + '/api/send', {
+      method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: cfg.GEORGE_AUTH },
+      body: JSON.stringify({ to: req.to, subject: req.subject, body: req.body }),
+    });
+    const j = await r.json().catch(() => ({}));
+    if (!r.ok) return { ok: false, status: 'error', reason: `George ${r.status}`, detail: j, plan };
+    return { ok: true, status: 'posted', channel: 'george', detail: j, plan };
+  } catch (e) { return { ok: false, status: 'error', reason: 'George unreachable: ' + e.message, plan }; }
+}
+
+/**
+ * The single guarded entry point. Returns {gated:true, plan} unless ALL gates pass.
+ */
+async function executeTarget(item, target, opts = {}) {
+  if (!cfg.TARGETS.includes(target)) return { ok: false, error: 'unknown target' };
+  const plan = planFor(item, target);
+  const approved = item.targets && item.targets[target] === 'approved';
+  const liveRequested = opts.live === true && opts.confirm === item.id;
+  const liveEnabled = cfg.EXECUTORS_LIVE;
+
+  // GATE 1: per-item approval must exist first.
+  if (!approved) {
+    return { ok: false, gated: true, reason: 'target not per-item approved', plan };
+  }
+  // GATE 2 + 3: live env switch AND a matching confirm token.
+  if (!liveEnabled || !liveRequested) {
+    store.logAction({ action: 'execute-gated', id: item.id, target, liveEnabled, liveRequested });
+    return { ok: false, gated: true,
+      reason: liveEnabled ? 'approved — awaiting explicit {live:true, confirm:<id>} to fire' : 'approved — SPEND_REVIEW_EXECUTORS_LIVE not set (gated to Steve)',
+      plan };
+  }
+  // All gates passed → fire.
+  let res;
+  if (target === 'google_review') res = await fireGoogleReview(item);
+  else if (target === 'amazon_review') res = await fireAmazonReview(item);
+  else res = await fireSellerEmail(item);
+  store.logAction({ action: 'execute-live', id: item.id, target, status: res.status, ok: res.ok });
+  return res;
+}
+
+/** Write a gated memo for a batch — NEVER executes. */
+function draftPendingApprovalMemo(items, note) {
+  try { fs.mkdirSync(cfg.PENDING_APPROVAL_DIR, { recursive: true }); } catch (e) {}
+  const ts = new Date().toISOString().replace(/[:.]/g, '-');
+  const file = path.join(cfg.PENDING_APPROVAL_DIR, `abramsego-spend-reviews-${ts}.md`);
+  const lines = [
+    `# AbramsEgo Spend → Reviews — batch execute approval`,
+    ``, `Drafted ${new Date().toLocaleString()} by vp-abramsego (TK-11433).`,
+    ``, `**${items.length} approved item(s)** are ready to post/send. Each fires ONLY per-item.`,
+    note ? `\n${note}\n` : '',
+    `## Recommendation: REVIEW EACH — APPROVE / REVISE / BLOCK`,
+    ``, `Nothing here has fired. To turn the executors live for a single item, from the running instance:`,
+    '```', `SPEND_REVIEW_EXECUTORS_LIVE=1  # set in AbramsEgo .env, then pm2 restart abramsego`,
+    `# then per item, click Post/Send in the panel (sends {live:true, confirm:<id>})`, '```',
+    ``, `| id | source | merchant | target | channel |`, `|----|--------|----------|--------|---------|`,
+    ...items.map((i) => (i.approvedTargets || []).map((t) => `| ${i.id} | ${i.source} | ${i.merchant} | ${t} | ${t === 'seller_email' ? 'George' : 'openclaw'} |`).join('\n')),
+  ];
+  fs.writeFileSync(file, lines.filter((l) => l !== undefined).join('\n'));
+  return file;
+}
+
+module.exports = { executeTarget, planFor, draftPendingApprovalMemo, openclaw };
diff --git a/lib/spend-reviews/resolver.js b/lib/spend-reviews/resolver.js
new file mode 100644
index 00000000..34452d17
--- /dev/null
+++ b/lib/spend-reviews/resolver.js
@@ -0,0 +1,85 @@
+'use strict';
+/**
+ * Merchant resolver — DTD verdict Option B (2026-09-10, vote 3/3).
+ *
+ * Google side: normalize + dedupe cache (merchant -> place_id JSONL). On a cache
+ * MISS we do NOT call a paid API — we emit a KEYLESS Google-Maps search deep-link
+ * and mark the target "unresolved (openclaw-at-post)": the real place_id is
+ * captured from the browser session when the approved review is posted, then
+ * cached. A paid Places API path exists but is OFF by default and gated behind a
+ * hard per-run cap (config.PLACES_API_ENABLED). Fuzzy matches are never merged
+ * silently — a cache hit must be an exact normalized-merchant match.
+ *
+ * Amazon side: resolved from order-line evidence only (ASIN + seller). A bank/CSV
+ * descriptor with no ASIN can NEVER identify an Amazon product — that item gets a
+ * Google-review path only.
+ */
+const cfg = require('./config');
+const store = require('./store');
+
+function mapsSearchUrl(merchant) {
+  return 'https://www.google.com/maps/search/?api=1&query=' + encodeURIComponent(merchant || '');
+}
+function writeReviewUrl(placeId) {
+  // Deep-link that lands the (already-open) openclaw Chrome on the write-review flow.
+  return 'https://search.google.com/local/writereview?placeid=' + encodeURIComponent(placeId);
+}
+
+/** Resolve the Google target for one item. Pure/$0 unless the gated Places path is on. */
+async function resolveGoogle(item, { placesBudget } = {}) {
+  const norm = item.norm_merchant || store.normalizeMerchant(item.merchant);
+  const cached = store.getPlace(norm);
+  if (cached && cached.place_id) {
+    return { place_id: cached.place_id, mapsUrl: mapsSearchUrl(item.display_merchant || item.merchant),
+      writeUrl: writeReviewUrl(cached.place_id), status: 'resolved', method: 'cache', cost: 0 };
+  }
+  // GATED, off-by-default paid fallback — only if Steve enabled it AND budget remains.
+  if (cfg.PLACES_API_ENABLED && cfg.PLACES_API_KEY && placesBudget && placesBudget.remaining > 0) {
+    try {
+      const url = 'https://maps.googleapis.com/maps/api/place/findplacefromtext/json'
+        + '?input=' + encodeURIComponent(item.display_merchant || item.merchant)
+        + '&inputtype=textquery&fields=place_id,name,formatted_address&key=' + cfg.PLACES_API_KEY;
+      const r = await fetch(url);
+      placesBudget.remaining -= 1; placesBudget.spent += cfg.PLACES_COST_PER_LOOKUP_USD;
+      const j = await r.json();
+      const cand = (j.candidates || [])[0];
+      if (cand && cand.place_id) {
+        store.setPlace(norm, cand.place_id, { name: cand.name, via: 'places-api' });
+        return { place_id: cand.place_id, mapsUrl: mapsSearchUrl(item.display_merchant || item.merchant),
+          writeUrl: writeReviewUrl(cand.place_id), status: 'resolved', method: 'places-api',
+          cost: cfg.PLACES_COST_PER_LOOKUP_USD };
+      }
+    } catch (e) { /* fall through to openclaw-at-post */ }
+  }
+  // $0 default path: keyless maps deep-link, place_id captured in-browser at post time.
+  return { place_id: null, mapsUrl: mapsSearchUrl(item.display_merchant || item.merchant), writeUrl: null,
+    status: 'unresolved', method: 'openclaw-at-post', cost: 0 };
+}
+
+/** Resolve the Amazon target from order-line evidence only. */
+function resolveAmazon(item) {
+  if (item.source !== 'amazon' || !item.asin) {
+    return { productUrl: null, seller: item.seller || null, status: 'no-product',
+      note: 'no ASIN — Amazon review/email not applicable (bank/receipt descriptor cannot identify an Amazon product)' };
+  }
+  return {
+    productUrl: 'https://www.amazon.com/dp/' + encodeURIComponent(item.asin),
+    reviewUrl: 'https://www.amazon.com/review/create-review?asin=' + encodeURIComponent(item.asin),
+    seller: item.seller || null,
+    status: 'resolved',
+  };
+}
+
+/** Cache a place_id captured from the openclaw browser session at post time. */
+function cachePlaceId(item, placeId, extra) {
+  const norm = item.norm_merchant || store.normalizeMerchant(item.merchant);
+  return store.setPlace(norm, placeId, Object.assign({ via: 'openclaw-at-post' }, extra || {}));
+}
+
+async function resolveItem(item, budget) {
+  const google = await resolveGoogle(item, budget);
+  const amazon = resolveAmazon(item);
+  return { google, amazon };
+}
+
+module.exports = { resolveItem, resolveGoogle, resolveAmazon, cachePlaceId, mapsSearchUrl, writeReviewUrl };
diff --git a/lib/spend-reviews/router.js b/lib/spend-reviews/router.js
new file mode 100644
index 00000000..9a4b9bb5
--- /dev/null
+++ b/lib/spend-reviews/router.js
@@ -0,0 +1,168 @@
+'use strict';
+/**
+ * Spend → Reviews — express router. Mounted at /api/spend in server.js AFTER the
+ * Basic-Auth middleware, so every route here is auth-gated. The post/send routes
+ * are additionally per-item gated (see executors.js).
+ */
+const express = require('express');
+const cfg = require('./config');
+const store = require('./store');
+const resolver = require('./resolver');
+const drafts = require('./drafts');
+const executors = require('./executors');
+
+const router = express.Router();
+
+// Adapters are independent files (built as separate units). Load lazily + safely
+// so a missing/half-written adapter can never crash the whole dashboard.
+function loadAdapter(source) {
+  try { return require('./adapters/' + source); }
+  catch (e) { return { ingest: async () => ({ ok: false, items: [], meta: { source, error: 'adapter not available: ' + e.message } }) }; }
+}
+
+function newBudget() { return { remaining: cfg.PLACES_MAX_LOOKUPS_PER_RUN, spent: 0 }; }
+
+async function resolveAndDraft(item, budget) {
+  const resolved = await resolver.resolveItem(item, budget);
+  const withResolved = Object.assign({}, item, { resolved });
+  const d = drafts.generateAll(withResolved);
+  const targets = Object.assign({}, item.targets);
+  for (const t of cfg.TARGETS) {
+    if (d[t] && (targets[t] === 'none' || !targets[t])) targets[t] = 'draft';
+  }
+  return store.updateItem(item.id, { resolved, drafts: d, targets });
+}
+
+function summary() {
+  const items = store.readItems();
+  const s = { total: items.length, bySource: {}, byTarget: {}, resolvedGoogle: 0, unresolvedGoogle: 0,
+    amazonProducts: 0, drafted: 0, approved: 0, posted: 0, lastIngestAt: null };
+  for (const t of cfg.TARGETS) s.byTarget[t] = { draft: 0, approved: 0, posted: 0, skipped: 0 };
+  for (const it of items) {
+    s.bySource[it.source] = (s.bySource[it.source] || 0) + 1;
+    if (it.resolved && it.resolved.google) (it.resolved.google.place_id ? s.resolvedGoogle++ : s.unresolvedGoogle++);
+    if (it.resolved && it.resolved.amazon && it.resolved.amazon.status === 'resolved') s.amazonProducts++;
+    let anyDraft = false;
+    for (const t of cfg.TARGETS) {
+      const st = (it.targets && it.targets[t]) || 'none';
+      if (st !== 'none' && s.byTarget[t][st] !== undefined) s.byTarget[t][st]++;
+      if (st === 'draft' || st === 'approved' || st === 'posted') anyDraft = true;
+      if (st === 'approved') s.approved++;
+      if (st === 'posted') s.posted++;
+    }
+    if (anyDraft) s.drafted++;
+    if (!s.lastIngestAt || it.created_at > s.lastIngestAt) s.lastIngestAt = it.created_at;
+  }
+  return s;
+}
+
+router.get('/items', (req, res) => res.json({ ok: true, items: store.readItems(), summary: summary() }));
+router.get('/summary', (req, res) => res.json(summary()));
+
+// Ingest from a source adapter. amazon/gmail may report needsAction (a one-time
+// Steve login) instead of items — the panel surfaces that.
+router.post('/ingest/:source', async (req, res) => {
+  const source = req.params.source;
+  if (!cfg.SOURCES.includes(source)) return res.status(400).json({ ok: false, error: 'unknown source' });
+  try {
+    const adapter = loadAdapter(source);
+    const out = await adapter.ingest(Object.assign({}, req.body, { source }));
+    const rawItems = (out.items || []).map((i) => store.makeItem(Object.assign({ source }, i)));
+    const up = store.upsertItems(rawItems);
+    res.json({ ok: out.ok !== false, source, meta: out.meta || {}, ingested: up, needsAction: out.meta && out.meta.needsAction || null });
+  } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
+// CSV drop: accepts {csv:"<raw text>", filename?} OR reads files already dropped
+// into data/csv-drop/. Steve uploads a statement export; this parses it.
+router.post('/upload-csv', async (req, res) => {
+  try {
+    const adapter = loadAdapter('csv');
+    const out = await adapter.ingest({ source: 'csv', csv: req.body && req.body.csv, filename: req.body && req.body.filename });
+    const rawItems = (out.items || []).map((i) => store.makeItem(Object.assign({ source: 'csv' }, i)));
+    const up = store.upsertItems(rawItems);
+    res.json({ ok: out.ok !== false, meta: out.meta || {}, ingested: up });
+  } catch (e) { res.status(500).json({ ok: false, error: e.message }); }
+});
+
+// Resolve + draft one item, or all unresolved (bounded by the Places budget when on).
+router.post('/resolve/:id', async (req, res) => {
+  const it = store.getItem(req.params.id);
+  if (!it) return res.status(404).json({ ok: false, error: 'not found' });
+  const budget = newBudget();
+  const updated = await resolveAndDraft(it, budget);
+  res.json({ ok: true, item: updated, placesSpentUsd: budget.spent });
+});
+router.post('/resolve-all', async (req, res) => {
+  const budget = newBudget();
+  const items = store.readItems();
+  let done = 0;
+  for (const it of items) {
+    if (it.resolved && it.resolved.google) continue; // already resolved once
+    await resolveAndDraft(it, budget);
+    done++;
+    if (done >= (Number(req.body && req.body.max) || 200)) break;
+  }
+  res.json({ ok: true, resolved: done, placesSpentUsd: budget.spent, placesCostLabel: budget.spent ? `$${budget.spent.toFixed(3)}` : '$0 (local)' });
+});
+
+router.post('/draft/:id', (req, res) => {
+  const it = store.getItem(req.params.id);
+  if (!it) return res.status(404).json({ ok: false, error: 'not found' });
+  const d = drafts.generateAll(it);
+  const targets = Object.assign({}, it.targets);
+  for (const t of cfg.TARGETS) if (d[t] && (targets[t] === 'none' || !targets[t])) targets[t] = 'draft';
+  res.json({ ok: true, item: store.updateItem(it.id, { drafts: d, targets }) });
+});
+
+// Per-item human approval flip (the whole point — nothing posts without this).
+function setTarget(req, res, status) {
+  const { id, target } = req.body || {};
+  if (!cfg.TARGETS.includes(target)) return res.status(400).json({ ok: false, error: 'unknown target' });
+  const it = store.getItem(id);
+  if (!it) return res.status(404).json({ ok: false, error: 'not found' });
+  if (status === 'approved' && (!it.drafts || !it.drafts[target])) return res.status(400).json({ ok: false, error: 'no draft to approve for this target' });
+  const targets = Object.assign({}, it.targets, { [target]: status });
+  store.logAction({ action: 'set-status', id, target, status });
+  res.json({ ok: true, item: store.updateItem(id, { targets }) });
+}
+router.post('/approve', (req, res) => setTarget(req, res, 'approved'));
+router.post('/unapprove', (req, res) => setTarget(req, res, 'draft'));
+router.post('/skip', (req, res) => setTarget(req, res, 'skipped'));
+
+// Optional: set a resolved seller/brand email address on an item (for seller_email).
+router.post('/seller-email-to', (req, res) => {
+  const { id, to } = req.body || {};
+  const it = store.getItem(id);
+  if (!it) return res.status(404).json({ ok: false, error: 'not found' });
+  res.json({ ok: true, item: store.updateItem(id, { seller_email_to: (to || '').toString().slice(0, 200) }) });
+});
+
+// THE GATED EXECUTE. Per item + per target. Returns the plan (gated) unless every
+// gate passes; on a real post it captures a place_id / marks the target posted.
+router.post('/execute', async (req, res) => {
+  const { id, target, confirm, live } = req.body || {};
+  const it = store.getItem(id);
+  if (!it) return res.status(404).json({ ok: false, error: 'not found' });
+  const result = await executors.executeTarget(it, target, { confirm, live: live === true });
+  if (result.ok && result.status === 'posted') {
+    const targets = Object.assign({}, it.targets, { [target]: 'posted' });
+    store.updateItem(id, { targets });
+  }
+  res.json({ ok: !!result.ok, gated: !!result.gated, result });
+});
+
+// Draft a batch memo of every approved-but-unposted item to pending-approval. Fires nothing.
+router.post('/draft-memo', (req, res) => {
+  const items = store.readItems();
+  const approved = [];
+  for (const it of items) {
+    const ats = cfg.TARGETS.filter((t) => it.targets && it.targets[t] === 'approved');
+    if (ats.length) approved.push(Object.assign({}, it, { approvedTargets: ats }));
+  }
+  if (!approved.length) return res.json({ ok: true, note: 'no approved items to memo' });
+  const file = executors.draftPendingApprovalMemo(approved, req.body && req.body.note);
+  res.json({ ok: true, memo: file, count: approved.length });
+});
+
+module.exports = { router, summary };
diff --git a/lib/spend-reviews/store.js b/lib/spend-reviews/store.js
new file mode 100644
index 00000000..e2dbe3d9
--- /dev/null
+++ b/lib/spend-reviews/store.js
@@ -0,0 +1,163 @@
+'use strict';
+/**
+ * Spend → Reviews — storage + the common `spend_item` record contract.
+ *
+ * THE CONTRACT every ingestion adapter normalizes to (makeItem):
+ *   {
+ *     id,            // stable hash of dedupeKey
+ *     source,        // 'amazon' | 'gmail' | 'csv'
+ *     merchant,      // best display name of the business/brand
+ *     product,       // product/line-item name, or null
+ *     order_id,      // vendor order id, or null
+ *     asin,          // Amazon ASIN, or null
+ *     seller,        // Amazon seller/brand string, or null
+ *     date,          // ISO date of the purchase (best-effort)
+ *     amount,        // number (spend), 0 if unknown
+ *     currency,      // 'USD' default
+ *     url,           // source/product url, or null
+ *     raw,           // original row/string for audit
+ *     norm_merchant, // normalized merchant key (dedupe + cache join)
+ *     dedupeKey,
+ *     created_at,    // ISO ingestion time (drives the 🕓 card chip)
+ *     resolved: { google:{place_id,mapsUrl,status,method}, amazon:{productUrl,seller,status} },
+ *     drafts:   { google_review, amazon_review, seller_email },   // {text|subject/body, cost, generatedAt}
+ *     targets:  { google_review, amazon_review, seller_email },   // per-target lifecycle
+ *   }
+ *
+ * Per-target lifecycle status: 'none' -> 'draft' -> 'approved' -> 'posted' | 'skipped' | 'error'.
+ * Nothing posts/sends without the target reaching 'approved' (a human click) AND a
+ * confirm token at execute time — see executors.js.
+ */
+const fs = require('fs');
+const crypto = require('crypto');
+const cfg = require('./config');
+
+function ensureDir(p) { try { fs.mkdirSync(p, { recursive: true }); } catch (e) {} }
+ensureDir(cfg.DATA_DIR);
+ensureDir(cfg.CSV_DROP_DIR);
+
+function readJsonl(file) {
+  try {
+    return fs.readFileSync(file, 'utf8').split('\n').filter(Boolean).map((l) => {
+      try { return JSON.parse(l); } catch (e) { return null; }
+    }).filter(Boolean);
+  } catch (e) { return []; }
+}
+
+/** Normalize a messy merchant/descriptor string into a stable join/dedupe key. */
+function normalizeMerchant(s) {
+  if (!s) return '';
+  let x = String(s).toLowerCase();
+  // strip common processor/POS prefixes and noise
+  x = x.replace(/\b(sq|tst|pos|paypal|pp|ppd|amzn mktp us|amzn|amazon mktpl|amazon\.com|ach|dbt|crd|pmt|purchase|debit|pos debit|visa|mastercard)\b\*?/g, ' ');
+  x = x.replace(/[*#]+/g, ' ');
+  x = x.replace(/\bstore\s*#?\d+\b/g, ' ');            // store numbers
+  x = x.replace(/\b\d{2}\/\d{2}(\/\d{2,4})?\b/g, ' ');  // dates
+  x = x.replace(/\b\d{3}[-. ]?\d{3}[-. ]?\d{4}\b/g, ' '); // phone (BEFORE card-tail so 6464 isn't eaten)
+  x = x.replace(/\bx?\d{4,}\b/g, ' ');                   // card tails / long digit runs
+  x = x.replace(/\.(com|net|org|co)\b/g, ' ');           // tld
+  x = x.replace(/\bwww\b/g, ' ');
+  x = x.replace(/\b[a-z]{2}\b\s*$/g, ' ');               // trailing state
+  x = x.replace(/[^a-z0-9&' ]+/g, ' ');
+  x = x.replace(/\s+/g, ' ').trim();
+  return x;
+}
+
+/** Human-friendly display name from the normalized key (title-case). */
+function displayMerchant(merchant, norm) {
+  const base = (norm && norm.length >= 3) ? norm : String(merchant || '').toLowerCase();
+  const t = base.replace(/\b[a-z]/g, (c) => c.toUpperCase()).trim();
+  return t || String(merchant || '').trim();
+}
+
+function makeItem(input) {
+  const source = input.source;
+  const merchant = (input.merchant || '').toString().trim();
+  const norm = normalizeMerchant(merchant);
+  const date = input.date || null;
+  const amount = Number(input.amount) || 0;
+  // dedupe: prefer order_id; else normalized merchant + date + amount
+  const dedupeKey = input.order_id
+    ? `${source}:${input.order_id}${input.asin ? ':' + input.asin : ''}`
+    : `${source}:${norm}|${date || ''}|${amount}`;
+  const id = crypto.createHash('sha1').update(dedupeKey).digest('hex').slice(0, 12);
+  return {
+    id,
+    source,
+    merchant,
+    display_merchant: displayMerchant(merchant, norm),
+    product: input.product || null,
+    order_id: input.order_id || null,
+    asin: input.asin || null,
+    seller: input.seller || null,
+    date,
+    amount,
+    currency: input.currency || 'USD',
+    url: input.url || null,
+    raw: input.raw != null ? input.raw : null,
+    norm_merchant: norm,
+    dedupeKey,
+    created_at: new Date().toISOString(),
+    resolved: { google: null, amazon: null },
+    drafts: { google_review: null, amazon_review: null, seller_email: null },
+    targets: { google_review: 'none', amazon_review: 'none', seller_email: 'none' },
+  };
+}
+
+function readItems() { return readJsonl(cfg.ITEMS_FILE); }
+function getItem(id) { return readItems().find((i) => i.id === id) || null; }
+
+function writeItems(items) {
+  fs.writeFileSync(cfg.ITEMS_FILE, items.map((i) => JSON.stringify(i)).join('\n') + (items.length ? '\n' : ''));
+}
+
+/** Insert only genuinely-new items (by dedupeKey). Returns {added, skipped, total}. */
+function upsertItems(newItems) {
+  const existing = readItems();
+  const seen = new Set(existing.map((i) => i.dedupeKey));
+  let added = 0;
+  for (const it of newItems) {
+    if (!it || !it.dedupeKey) continue;
+    if (seen.has(it.dedupeKey)) continue;
+    existing.push(it); seen.add(it.dedupeKey); added++;
+  }
+  writeItems(existing);
+  return { added, skipped: newItems.length - added, total: existing.length };
+}
+
+/** Patch one item in place (shallow-merge top-level; caller passes nested objects whole). */
+function updateItem(id, patch) {
+  const items = readItems();
+  const idx = items.findIndex((i) => i.id === id);
+  if (idx < 0) return null;
+  items[idx] = Object.assign({}, items[idx], patch);
+  writeItems(items);
+  return items[idx];
+}
+
+// ---- merchant → place_id cache (DTD Option B) --------------------------------
+function getPlace(norm) {
+  if (!norm) return null;
+  const rows = readJsonl(cfg.PLACE_CACHE);
+  // last write wins
+  let hit = null;
+  for (const r of rows) if (r && r.norm === norm) hit = r;
+  return hit;
+}
+function setPlace(norm, place_id, extra) {
+  const row = Object.assign({ norm, place_id: place_id || null, at: new Date().toISOString() }, extra || {});
+  fs.appendFileSync(cfg.PLACE_CACHE, JSON.stringify(row) + '\n');
+  return row;
+}
+
+// ---- audit log of every approve / execute action ----------------------------
+function logAction(row) {
+  const r = Object.assign({ at: new Date().toISOString() }, row);
+  try { fs.appendFileSync(cfg.ACTIONS_LOG, JSON.stringify(r) + '\n'); } catch (e) {}
+  return r;
+}
+
+module.exports = {
+  normalizeMerchant, displayMerchant, makeItem, readItems, getItem, upsertItems, updateItem,
+  getPlace, setPlace, logAction, readJsonl,
+};
diff --git a/public/index.html b/public/index.html
index c33334f9..68226b3c 100644
--- a/public/index.html
+++ b/public/index.html
@@ -408,6 +408,21 @@
     <div id="crongrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:10px;max-height:560px;overflow:auto"></div>
   </section>
 
+  <!-- SPEND → REVIEWS (panel 9, TK-11433) — auto-review businesses Steve buys from -->
+  <section class="card span12" id="spend-card">
+    <h2>🧾 Spend → Reviews <span class="muted" id="spend-sub">· thank the businesses you buy from · <b style="color:var(--gold)">every post/send is per-item gated</b></span></h2>
+    <div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:8px">
+      <button type="button" data-ingest="amazon">⬇ Ingest Amazon</button>
+      <button type="button" data-ingest="gmail">✉ Scan Gmail receipts</button>
+      <label class="btnlike" style="cursor:pointer;padding:6px 10px;border:1px solid var(--line,#2a2f3a);border-radius:6px;font-size:12px">⬆ Upload statement CSV<input type="file" id="spend-csv" accept=".csv,text/csv" style="display:none"></label>
+      <button type="button" id="spend-resolve">↻ Resolve + Draft all</button>
+      <button type="button" id="spend-memo">📝 Draft approval memo</button>
+      <span class="muted" id="spend-note" style="font-size:11px"></span>
+    </div>
+    <div id="spend-ctl"></div>
+    <div id="spendgrid" style="max-height:640px;overflow:auto"></div>
+  </section>
+
   <!-- AI CHAT -->
   <section class="card span12 chat">
     <h2>🤖 Ask AbramsEgo <span class="muted">· grounded in the live snapshot · cost shown per query</span></h2>
@@ -1171,7 +1186,92 @@ async function loadWpb(){
   }
   if(!w.launched){ const sub=document.getElementById('wpb-sub'); if(sub && !sub.dataset.pre){ sub.dataset.pre='1'; sub.innerHTML += ' · <span style="color:var(--muted,#9aa0ad)">🌱 pre-launch ($0)</span>'; } }
 }
-async function tick(){ if(document.hidden) return; await load(); loadWpb(); if(!_booted){ _booted=true; playEntrance(); } }
+// ---- SPEND → REVIEWS panel (TK-11433) ---------------------------------------
+const SPEND_TARGETS=[['google_review','Google review'],['amazon_review','Amazon review'],['seller_email','Seller thank-you email']];
+function spendStatusPill(st){
+  const map={draft:['DRAFT','warn'],approved:['APPROVED','good'],posted:['POSTED ✅','good'],skipped:['skipped','muted'],none:['—','muted']};
+  const [t,c]=map[st]||map.none; return `<span class="pill ${c}">${t}</span>`;
+}
+function spendDraftBody(t,d){
+  if(!d) return '';
+  if(t==='seller_email') return `<div class="dim" style="font-size:11px"><b>Subj:</b> ${esc(d.subject||'')}</div><div style="white-space:pre-wrap;font-size:12px;margin-top:2px">${esc(d.body||'')}</div>`;
+  return `${d.title?`<div class="dim" style="font-size:11px"><b>${esc(d.title)}</b> · ★${d.rating||5}</div>`:''}<div style="font-size:12px;margin-top:2px">${esc(d.text||'')}</div>`;
+}
+function spendTargetBlock(it,t,label){
+  const d=it.drafts&&it.drafts[t]; if(!d) return '';
+  const st=(it.targets&&it.targets[t])||'none';
+  const g=it.resolved&&it.resolved.google, chan=t==='seller_email'?'George email':'openclaw (real Chrome)';
+  let ctl='';
+  if(st==='draft') ctl=`<button type="button" onclick="spendSet('${it.id}','${t}','approve')">✔ Approve</button> <button type="button" class="ghost" onclick="spendSet('${it.id}','${t}','skip')">skip</button>`;
+  else if(st==='approved') ctl=`<button type="button" onclick="spendExecute('${it.id}','${t}')">${t==='seller_email'?'✉ Send':'📤 Post'} (gated)</button> <button type="button" class="ghost" onclick="spendSet('${it.id}','${t}','unapprove')">unapprove</button>`;
+  else if(st==='posted') ctl=`<span class="good">done</span>`;
+  return `<div class="spend-target" style="border-top:1px solid var(--line,#242a35);padding-top:6px;margin-top:6px">
+    <div style="display:flex;justify-content:space-between;align-items:center;gap:6px"><b style="font-size:12px">${label}</b> ${spendStatusPill(st)}</div>
+    ${spendDraftBody(t,d)}
+    <div class="dim" style="font-size:10px;margin-top:3px">via ${chan} · ${d.costLabel||'$0 (local)'}${t!=='seller_email'&&g?` · ${g.status==='resolved'?'place cached':'openclaw resolves + confirms at post'}`:''}</div>
+    <div style="margin-top:4px;display:flex;gap:6px">${ctl}</div>
+  </div>`;
+}
+function spendCard(it){
+  const src={amazon:'🅰 Amazon',gmail:'✉ Gmail',csv:'💳 CSV'}[it.source]||it.source;
+  const g=it.resolved&&it.resolved.google, a=it.resolved&&it.resolved.amazon;
+  const gLine=g?`<a href="${esc(g.mapsUrl)}" target="_blank" rel="noopener noreferrer">Google: ${g.status==='resolved'?'resolved':'find business'}</a>`:'<span class="muted">not resolved</span>';
+  const aLine=a&&a.status==='resolved'?` · <a href="${esc(a.productUrl)}" target="_blank" rel="noopener noreferrer">Amazon product</a>`:'';
+  const blocks=SPEND_TARGETS.map(([t,l])=>spendTargetBlock(it,t,l)).join('');
+  return `<div class="card2" style="border:1px solid var(--line,#242a35);border-radius:8px;padding:10px">
+    <div style="display:flex;justify-content:space-between;gap:8px"><b>${esc(it.display_merchant||it.merchant||'?')}</b><span class="dim" style="font-size:11px">${src}</span></div>
+    ${it.product?`<div class="dim" style="font-size:11px">${esc(it.product)}</div>`:''}
+    <div style="font-size:11px;margin-top:2px">${it.amount?usd(it.amount):''} · ${gLine}${aLine}</div>
+    <div style="margin-top:3px">${whenChip(it.created_at)}</div>
+    ${blocks||'<div class="muted" style="font-size:11px;margin-top:6px">no drafts yet — click Resolve + Draft</div>'}
+  </div>`;
+}
+const SPEND_SORTS=[
+  {k:'needs',label:'Needs approval',cmp:(a,b)=>spendPend(b)-spendPend(a)||tsOf(b.created_at)-tsOf(a.created_at)},
+  {k:'new',label:'Newest',cmp:(a,b)=>tsOf(b.created_at)-tsOf(a.created_at)},
+  {k:'amt',label:'Amount ↓',cmp:(a,b)=>(b.amount||0)-(a.amount||0)},
+  {k:'merch',label:'Merchant A→Z',cmp:(a,b)=>String(a.display_merchant||a.merchant).localeCompare(String(b.display_merchant||b.merchant))},
+  {k:'src',label:'Source',cmp:(a,b)=>String(a.source).localeCompare(String(b.source))},
+];
+function spendPend(it){return SPEND_TARGETS.reduce((n,[t])=>n+((it.targets&&it.targets[t]==='draft')?1:0),0);}
+let _spendGridReady=false;
+async function loadSpendReviews(){
+  if(!_spendGridReady && $('spendgrid')){
+    initGrid('spend',{controlsHost:$('spend-ctl'),host:$('spendgrid'),sortModes:SPEND_SORTS,defaultCols:3,minCols:1,maxCols:5,batch:24,render:spendCard,empty:'No spend items yet — ingest Amazon, scan Gmail receipts, or upload a statement CSV.'});
+    _spendGridReady=true;
+  }
+  let j; try{ j=await (await fetch('/api/spend/items')).json(); }catch(e){ return; }
+  const items=(j&&j.items)||[]; feedGrid('spend',items);
+  const s=(j&&j.summary)||{};
+  const note=$('spend-note'); if(note) note.textContent=`${s.total||0} items · ${s.drafted||0} drafted · ${s.approved||0} approved · ${s.posted||0} posted · Google resolved ${s.resolvedGoogle||0}/${(s.resolvedGoogle||0)+(s.unresolvedGoogle||0)}`;
+}
+async function spendPost(path,body){ const r=await fetch('/api/spend/'+path,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body||{})}); return r.json(); }
+async function spendSet(id,target,which){ const map={approve:'approve',unapprove:'unapprove',skip:'skip'}; const j=await spendPost(map[which],{id,target}); if(j&&j.error) alert(j.error); await loadSpendReviews(); }
+async function spendExecute(id,target){
+  const j=await spendPost('execute',{id,target,live:true,confirm:id});
+  const r=j&&j.result||{};
+  if(j&&j.gated){ alert('GATED — nothing sent.\n\n'+(r.reason||'')+'\n\nThe executor is built one-click-from-firing but stays off until Steve sets SPEND_REVIEW_EXECUTORS_LIVE=1 in AbramsEgo .env. Use "Draft approval memo" to queue it for Steve.'); }
+  else if(r.status==='posted'){ alert('Sent ✓'); }
+  else if(r.status==='needs_verify'){ alert('Opened in Chrome — verify submission before it counts as posted.\n'+(r.reason||'')); }
+  else if(r.reason){ alert(r.reason); }
+  await loadSpendReviews();
+}
+async function spendIngest(source){
+  const note=$('spend-note'); if(note) note.textContent='ingesting '+source+'…';
+  const j=await spendPost('ingest/'+source,{});
+  if(j&&j.needsAction){ alert('Action needed for '+source+':\n\n'+(j.meta&&j.meta.note||j.needsAction)+(j.meta&&j.meta.loginCmd?'\n\n'+j.meta.loginCmd:'')); }
+  else if(j&&j.ingested){ /* ok */ }
+  else if(j&&j.meta&&j.meta.note){ if(note) note.textContent=j.meta.note; }
+  await loadSpendReviews();
+}
+document.addEventListener('click',(e)=>{ const b=e.target.closest('[data-ingest]'); if(b) spendIngest(b.getAttribute('data-ingest')); });
+(function wireSpend(){
+  const rb=$('spend-resolve'); if(rb) rb.addEventListener('click',async()=>{ const n=$('spend-note'); if(n)n.textContent='resolving + drafting…'; const j=await spendPost('resolve-all',{}); if(n)n.textContent=`resolved ${j.resolved||0} · ${j.placesCostLabel||'$0 (local)'}`; await loadSpendReviews(); });
+  const mb=$('spend-memo'); if(mb) mb.addEventListener('click',async()=>{ const j=await spendPost('draft-memo',{}); alert(j&&j.memo?('Approval memo drafted for Steve:\n'+j.memo+'\n('+j.count+' approved item(s))'):(j&&j.note||'nothing to memo')); });
+  const cf=$('spend-csv'); if(cf) cf.addEventListener('change',async()=>{ const f=cf.files&&cf.files[0]; if(!f) return; const csv=await f.text(); const n=$('spend-note'); if(n)n.textContent='parsing '+f.name+'…'; const j=await spendPost('upload-csv',{csv,filename:f.name}); if(n)n.textContent=(j.meta&&j.meta.note)||'uploaded'; await loadSpendReviews(); cf.value=''; });
+})();
+
+async function tick(){ if(document.hidden) return; await load(); loadWpb(); loadSpendReviews(); if(!_booted){ _booted=true; playEntrance(); } }
 tick(); setInterval(tick, 15000);
 </script>
 <script>window.UNBLOCK_CONFIG={surface:'abramsego',endpoint:'/api/dispatch',statusEndpoint:'/api/yolo/status'};</script>
diff --git a/server.js b/server.js
index 6547d257..bda668a5 100644
--- a/server.js
+++ b/server.js
@@ -987,6 +987,8 @@ async function doBuild() {
     wins: summarizeWins(wins),
     officers: summarizeOfficers(officers),
     cost, budgets, revenue, pnl, usage,
+    // Spend → Reviews panel summary (counts only; full items via /api/spend/items)
+    spendReviews: (() => { try { return require('./lib/spend-reviews/router').summary(); } catch (e) { return { error: e.message }; } })(),
     // compact form folded into the snapshot for at-a-glance headers
     usageSummary: {
       activeSessions: usage.sessions && usage.sessions.active,
@@ -1179,6 +1181,16 @@ app.post('/api/dispatch', async (req, res) => {
   } catch (e) { res.status(502).json({ error: 'CNCP unreachable: ' + e.message }); }
 });
 
+// ── Spend → Reviews (TK-11433) ───────────────────────────────────────────────
+// Mounted AFTER the Basic-Auth middleware, so every /api/spend/* route is gated.
+// Ingestion + resolver + drafts + queue are reversible LOCAL code; the post/send
+// executors are per-item human-gated inside the module (executors.js). Loaded
+// defensively so a module error can never take down the dashboard.
+try {
+  const spendReviews = require('./lib/spend-reviews/router');
+  app.use('/api/spend', spendReviews.router);
+} catch (e) { console.error('[spend-reviews] router mount failed:', e.message); }
+
 const REVENUE_ENGINES = ['sell_product', 'affiliate', 'billable_work', 'ads', 'licensing'];
 app.post('/api/revenue/record', async (req, res) => {
   const b = req.body || {};

← 6e882c0a auto-data-snapshot: 2026-09-10T13:39:50 (1 data files) — dat  ·  back to AbramsEgo  ·  Spend → Reviews: bind approval to content (anti-drift seal) ddc28d6a →