[object Object]

← back to AbramsOS

AbramsOS: class-action settlement claim-filler (Mode feed → openclaw fill-not-submit)

7ff4ec3f2a3a08d13f5a4a0d2dbd25b149b7439a · 2026-08-07 19:05:01 -0700 · Steve

- lib/mode-claims.js: deterministic parser for the Mode Class Actions newsletter
  (handles Mode's swapped-URL quirk via slug↔name token matching)
- 0016_settlement_claims.sql: settlement_claim + settlement_fill_job tables
- ingest-mode-claims.js: native Gmail-connector ingester (idempotent, $0)
- openclaw-claim-filler.js: builds a portal-driver brief that PREFILLS and STOPS
  at the perjury attestation/submit; only for eligibility_state='eligible'
- mode-claims-watch.js + launchd (15m): 'as soon as email lands' trigger
- /settlements dashboard: eligibility toggle + gated 'Prefill via openclaw'
- 6-month Dan backfill artifact: 19 distinct settlements, 12 open

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 7ff4ec3f2a3a08d13f5a4a0d2dbd25b149b7439a
Author: Steve <steve@designerwallcoverings.com>
Date:   Fri Aug 7 19:05:01 2026 -0700

    AbramsOS: class-action settlement claim-filler (Mode feed → openclaw fill-not-submit)
    
    - lib/mode-claims.js: deterministic parser for the Mode Class Actions newsletter
      (handles Mode's swapped-URL quirk via slug↔name token matching)
    - 0016_settlement_claims.sql: settlement_claim + settlement_fill_job tables
    - ingest-mode-claims.js: native Gmail-connector ingester (idempotent, $0)
    - openclaw-claim-filler.js: builds a portal-driver brief that PREFILLS and STOPS
      at the perjury attestation/submit; only for eligibility_state='eligible'
    - mode-claims-watch.js + launchd (15m): 'as soon as email lands' trigger
    - /settlements dashboard: eligibility toggle + gated 'Prefill via openclaw'
    - 6-month Dan backfill artifact: 19 distinct settlements, 12 open
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 data/mode-emails-raw.js                           | 213 ++++++++++++++++++++++
 deploy/com.steve.abramsos-mode-claims-watch.plist |  24 +++
 lib/mode-claims.js                                | 136 ++++++++++++++
 lib/openclaw-claim-filler.js                      |  71 ++++++++
 routes/settlements.js                             |  64 +++++++
 scripts/backfill-mode-claims.js                   |  46 +++++
 scripts/ingest-mode-claims.js                     |  62 +++++++
 scripts/mode-claims-watch.js                      |  52 ++++++
 server.js                                         |   2 +
 views/settlements.ejs                             |  64 +++++++
 10 files changed, 734 insertions(+)

diff --git a/data/mode-emails-raw.js b/data/mode-emails-raw.js
new file mode 100644
index 0000000..9a72579
--- /dev/null
+++ b/data/mode-emails-raw.js
@@ -0,0 +1,213 @@
+// Fixture: raw Mode Class Actions newsletter bodies pulled via George (steve-personal),
+// 2026-07-27 → 2026-08-07. Used to seed the backfill + as a parser regression corpus.
+// (The live ingester pulls these itself via lib/gmail-fetcher once Postgres is up.)
+module.exports = [
+{ id:'19fdde2f8fb5dd99', date:'2026-08-07', body:`
+Onsite Mammography Settlement – Up to $5,000
+Received a notice about the data breach?
+• Up to $5,000 for documented losses
+• A pro rata cash payment
+• 3 years of credit and medical data monitoring
+Deadline: August 11, 2026
+https://modeclassactionsdaily.com/up-to-5000-onsite-mammography-data-breach-settlement/?utm_source=email
+
+Hard Rock Stadium Settlement — Up to $2,000 Per Ticket
+Bought a valid ticket to the 2024 Copa América Final but couldn't get into the stadium?
+Deadline: August 11, 2026
+https://modeclassactionsdaily.com/aetna-life-insurance-2-56m-l-adr-surgery-denial-settlement/?utm_source=email
+
+Aetna L-ADR Surgery Settlement – Up to $55,000
+If you were denied coverage for a qualifying lumbar artificial disc replacement surgery.
+Deadline: August 11, 2026
+https://modeclassactionsdaily.com/hard-rock-stadium-14m-copa-america-final-settlement-2024-up-to-2000-per-ticket/?utm_source=email
+
+Insurance Kickbacks Settlement – $875 Per Eligible Loan
+If you had a residential mortgage loan with PHH, this settlement provides a fixed payment. No receipts are required to file a claim.
+Deadline: August 11, 2026
+https://modeclassactionsdaily.com/phh-mortgage-insurance-kickbacks-settlement-claim-875-per-loan/?utm_source=email
+` },
+{ id:'19fd8bc974846c3b', date:'2026-08-06', body:`
+Domino's Pizza Job Applicant Settlement
+If you applied for a job with certain Domino's Pizza franchise operators in Washington, you may automatically receive a payment.
+• No claim form required
+• Payments will be sent automatically if the settlement is approved
+Deadline to exclude yourself: Aug. 10, 2026
+
+505 Quick Cash & Pawn Settlement
+Had a pawn loan with 505 Quick Cash & Pawn? This settlement provides automatic cash payments, at least $20 per eligible class member.
+No action required, but make sure you update your mailing address if needed.
+Deadline to exclude yourself: Aug. 11, 2026
+` },
+{ id:'19fd39643b8765d2', date:'2026-08-05', body:`
+AT&T Wage & Hour Settlement (Automatic Payment)
+Worked as a nonexempt employee for certain AT&T companies? If you're included, you don't need to file a claim. No action required.
+https://modeclassactionsdaily.com/att-1-75m-wage-and-hour-settlement-washington-employees-may-receive-money/?utm_source=email
+
+Nutricost Magnesium Glycinate Settlement – Up to $19.95 Per Product
+Bought Nutricost Magnesium Glycinate supplements? Some purchases can be claimed without proof of purchase.
+Deadline: August 7, 2026
+https://modeclassactionsdaily.com/no-proof-required-nutricost-magnesium-glycinate-settlement/?utm_source=email
+
+Splish Splash Ticket Fee Settlement
+Purchased electronic tickets through the official website and paid a processing fee?
+Deadline: August 10, 2026
+https://modeclassactionsdaily.com/splish-splash-ticket-fee-settlement/?utm_source=email
+
+LCPtracker Data Breach Settlement – Up to $2,500
+• Up to $2,500 for documented losses
+• Estimated $50 alternative cash payment
+• Two years of free credit monitoring
+Deadline: August 8, 2026
+https://modeclassactionsdaily.com/lcptracker-data-breach-settlement/?utm_source=email
+
+Triage Staffing Data Breach Settlement – Up to $3,000
+• Up to $3,000 for documented losses
+• $50 alternative cash payment
+Deadline: August 10, 2026
+https://modeclassactionsdaily.com/triage-staffing-data-breach-settlement/?utm_source=email
+
+The Polyclinic Disability Accommodation Settlement – $350K
+If you requested a hearing, vision, or speech accommodation while receiving care from The Polyclinic and it wasn't properly provided.
+Deadline: August 8, 2026
+https://modeclassactionsdaily.com/the-polyclinic-350k-disability-accommodation-settlement/?utm_source=email
+` },
+{ id:'19fce6fe4a0ecf91', date:'2026-08-04', body:`
+Albany Park Store Credit Settlement – Automatic $115 Store Credit or Cash
+Bought furniture from AlbanyPark.com? Eligible customers will automatically receive a $115 store credit. If you'd rather receive $115 in cash, you can submit a claim instead.
+Deadline: August 18, 2026
+https://modeclassactionsdaily.com/albany-park-deceptive-discount-settlement/?utm_source=email
+
+Wright Brothers Construction Data Breach Settlement – Up to $4,767
+• Up to $4,767 for documented expenses, fraud losses, and lost time
+• $50 alternative cash payment
+• Three years of credit monitoring
+Deadline: August 18, 2026
+https://modeclassactionsdaily.com/wright-brothers-construction-data-breach-settlement/?utm_source=email
+` },
+{ id:'19fc9498809e3857', date:'2026-08-03', body:`
+Western Union Consumer Fraud Remission – Up to full documented losses
+The U.S. Department of Justice has reopened the remission program for additional eligible victims. If you lost money through qualifying fraud schemes (romance scams, lottery scams, fake job offers).
+Deadline: August 19, 2026
+https://modeclassactionsdaily.com/western-union-586-million-consumer-fraud-remission/?utm_source=email
+
+Jiffy Lube Job Posting Settlement – Estimated $1,519
+If the job posting didn't include the required pay range and you received a settlement notice, you may qualify for an estimated $1,519.58 payment, capped at $5,000.
+Deadline: August 21, 2026
+https://modeclassactionsdaily.com/claim-an-estimated-1519-jiffy-lube-washington-job-posting-settlement/?utm_source=email
+` },
+{ id:'19fc4232e70d233a', date:'2026-08-02', body:`
+Ciuni & Panichi Data Breach Settlement – Estimated $125 Cash
+• Estimated $125 cash payment
+• Up to $5,000 for documented losses
+Deadline: August 18, 2026
+https://modeclassactionsdaily.com/claim-125-cash-ciuni-panichi-data-breach-settlement/?utm_source=email
+
+Country Bank Overdraft Settlement – Automatic Payments
+If you're included in the settlement, no claim form is required. Eligible customers will automatically receive an account credit. No claim required.
+https://modeclassactionsdaily.com/country-bank-overdraft-fee-495000-settlement/?utm_source=email
+
+International Motors Wellness Program Settlement – No Claim Required
+No need to file a claim to receive a payment.
+Deadline: August 13, 2026
+https://modeclassactionsdaily.com/international-motors-wellness-program-surcharge-settlement/?utm_source=email
+
+Alta Resources Data Breach Settlement – Up to $2,000
+• Estimated $50 alternative cash payment
+• Up to $2,000 for documented expenses
+Deadline: August 17, 2026
+https://modeclassactionsdaily.com/alta-resources-675000-data-breach-class-action-settlement/?utm_source=email
+` },
+{ id:'19fbefcd311f2074', date:'2026-08-01', body:`
+Jennmar Wage Settlement – Automatic Payments ($3 Million)
+Worked as a nonexempt hourly employee for Jennmar Corp. of Virginia? Eligible employees do not need to file a claim.
+Deadline: August 3, 2026
+https://modeclassactionsdaily.com/jennmar-wage-settlement-eligible-workers-may-receive-money-from-3-million-settlement/?utm_source=email
+
+First Federal Bank of Kansas City Overdraft Settlement – Automatic
+Paid overdraft fees on a personal checking account? No claim form is required.
+Deadline: August 3, 2026
+https://modeclassactionsdaily.com/first-federal-bank-of-kansas-city-300k-overdraft-settlement/?utm_source=email
+
+C-Plant Federal Credit Union Overdraft Settlement – Automatic
+This settlement requires no claim form.
+Deadline: August 5, 2026
+https://modeclassactionsdaily.com/c-plant-federal-credit-union-1-05m-overdraft-settlement/?utm_source=email
+
+Mobile Home Park Settlement – About $199 Per Resident
+If you lived in one of six participating New Mexico mobile home parks, no claim form is required.
+Deadline: August 3, 2026
+https://modeclassactionsdaily.com/claim-199-from-the-mobile-home-park-fee-violation-class-action-settlement/?utm_source=email
+` },
+{ id:'19fb9d6772d2da7c', date:'2026-07-31', body:`
+DOWL LLC Pay Transparency Settlement – Up to $5,000
+Certain DOWL LLC job postings failed to include legally required wage ranges. Eligible applicants may receive an estimated $1,658.76, up to $5,000.
+Deadline: August 3, 2026
+https://modeclassactionsdaily.com/dowl-llc-1-2m-washington-pay-transparency-settlement/?utm_source=email
+
+Glacier Supply Group Job Applicant Settlement – $1,582.50–$5,000
+Applied for a job with Glacier Supply Group in Washington? If the posting didn't include the required pay range.
+Deadline: August 3, 2026
+https://modeclassactionsdaily.com/glacier-supply-group-washington-job-posting-settlement/?utm_source=email
+
+Estée Lauder Securities Settlement – $210 Million Fund
+Purchased stock between 2022 and 2025? Investors who submit valid claims may receive a share of the fund.
+Deadline: August 5, 2026
+https://modeclassactionsdaily.com/estee-lauder-210-million-securities-class-action-settlement/?utm_source=email
+
+Shimano Crankset Settlement – Reimbursement + Extended Warranty
+Owned an eligible Shimano Ultegra or Dura-Ace crankset? If you paid to replace a crankset because of bonding separation. A signed declaration may satisfy the documentation requirement.
+Deadline: August 4, 2026
+https://modeclassactionsdaily.com/shimano-crankset-settlement-get-reimbursed-for-replacement-costs-or-enjoy-an-extended-warranty-through-2027/?utm_source=email
+
+Cook County Hiring Settlement – Up to $10,000
+Applied for a correctional officer position and believe discriminatory hiring tests prevented you from being hired?
+Deadline: August 5, 2026
+https://modeclassactionsdaily.com/cook-county-5-68m-race-discrimination-in-hiring-settlement/?utm_source=email
+` },
+{ id:'19faf89d74314855', date:'2026-07-29', body:`
+QVAR Inhaler Price-Fixing Settlement – $35 Million Fund
+Purchased or paid for QVAR or QVAR RediHaler asthma inhalers between 2015 and 2025?
+Deadline: July 31, 2026
+https://modeclassactionsdaily.com/qvar-35m-antitrust-class-action-settlement/?utm_source=email
+
+Kickback Jack's Hiring Settlement – $1.1 Million Fund
+If you're a man who applied for a qualifying front-of-house position at Kickback Jack's and believe you weren't hired because of your sex.
+Deadline: August 3, 2026
+https://modeclassactionsdaily.com/kickback-jacks-1-1-million-sex-discrimination-settlement/?utm_source=email
+
+Oldcastle APG West Settlement – Estimated $1,664 Payment
+Applied for a job with Oldcastle APG West in Washington? If the posting didn't include required pay info.
+Deadline: July 31, 2026
+https://modeclassactionsdaily.com/oldcastle-apg-west-2-25m-washington-job-posting-settlement/?utm_source=email
+
+Choice Home Warranty Settlement – Refunds Available
+If you purchased a Choice Home Warranty contract over the phone for an Arizona property.
+Deadline: August 1, 2026
+https://modeclassactionsdaily.com/choice-home-warranty-11-8m-settlement/?utm_source=email
+` },
+{ id:'19faa6370db11c0d', date:'2026-07-28', body:`
+EMM Loans Data Breach Settlement – $50 Payment
+Received a notice about the EMM Loans data breach? You can choose a $50 cash payment with no proof required, or claim up to $4,000 for documented losses.
+Deadline: August 17, 2026
+https://modeclassactionsdaily.com/emm-loans-data-breach-class-action-settlement/?utm_source=email
+
+SolarEdge Securities Settlement – $55 Million Fund
+Purchased SolarEdge Technologies stock in 2023? You might be eligible to file for a share of the fund.
+Deadline: August 17, 2026
+https://modeclassactionsdaily.com/solaredge-securities-settlement-investors-may-claim-part-of-55-million-fund/?utm_source=email
+` },
+{ id:'19fa53d16539f0cc', date:'2026-07-27', body:`
+AT&T Wage & Hour Settlement – Automatic Payments
+Worked for certain AT&T companies? If you're included, no claim form is required.
+Deadline: August 6, 2026
+https://modeclassactionsdaily.com/att-1-75m-wage-and-hour-settlement-washington-employees-may-receive-money/?utm_source=email
+
+Mission Community Hospital / Deanco Healthcare Data Breach Settlement – Up to $5,100
+Received a data breach notice from Mission Community Hospital, Deanco Healthcare, or one of its affiliates?
+• Up to $5,000 for documented losses
+• $100 California statutory payment for eligible California residents
+• Two years of medical monitoring
+Deadline: August 12, 2026
+https://modeclassactionsdaily.com/up-to-5100-mission-community-hospital-deanco-healthcare-data-breach-settlement/?utm_source=email
+` },
+];
diff --git a/deploy/com.steve.abramsos-mode-claims-watch.plist b/deploy/com.steve.abramsos-mode-claims-watch.plist
new file mode 100644
index 0000000..94b1fd3
--- /dev/null
+++ b/deploy/com.steve.abramsos-mode-claims-watch.plist
@@ -0,0 +1,24 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+  <key>Label</key><string>com.steve.abramsos-mode-claims-watch</string>
+  <key>ProgramArguments</key>
+  <array>
+    <string>/opt/homebrew/bin/node</string>
+    <string>/Users/macstudio3/Projects/AbramsOS/scripts/mode-claims-watch.js</string>
+  </array>
+  <key>WorkingDirectory</key><string>/Users/macstudio3/Projects/AbramsOS</string>
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PG_HOST</key><string>/tmp</string>
+    <key>PG_DATABASE</key><string>abrams_os</string>
+    <key>PG_USER</key><string>stevestudio2</string>
+    <key>PATH</key><string>/opt/homebrew/bin:/usr/bin:/bin</string>
+  </dict>
+  <key>StartInterval</key><integer>900</integer>
+  <key>RunAtLoad</key><true/>
+  <key>StandardOutPath</key><string>/Users/macstudio3/Projects/AbramsOS/logs/mode-claims-watch.out.log</string>
+  <key>StandardErrorPath</key><string>/Users/macstudio3/Projects/AbramsOS/logs/mode-claims-watch.err.log</string>
+</dict>
+</plist>
diff --git a/lib/mode-claims.js b/lib/mode-claims.js
new file mode 100644
index 0000000..d1afcb6
--- /dev/null
+++ b/lib/mode-claims.js
@@ -0,0 +1,136 @@
+// lib/mode-claims.js — parse a "Mode Class Actions" daily newsletter body into distinct
+// settlement claims. Dependency-free + deterministic so it can run in CI or DB-free.
+//
+// The newsletter lists 1-5 settlements per email, each as:
+//   <Name> Settlement – Up to $X        (heading: name + payout, separated by – / — / -)
+//   If you ... / Bought ... / Received … (the plain-English eligibility test)
+//   • benefit bullets
+//   Deadline: <Month DD, YYYY>
+//   https://modeclassactionsdaily.com/<slug>/?utm_source=email
+//
+// Quirk handled: Mode occasionally shifts the URL under the WRONG heading (observed 2026-08-07:
+// Hard Rock heading carried the Aetna slug and vice-versa). So we match each heading to the mode
+// URL whose slug shares the most words with the heading name — not by position.
+
+const MONTHS = { january:0,february:1,march:2,april:3,may:4,june:5,july:6,august:7,september:8,october:9,november:10,december:11,
+  jan:0,feb:1,mar:2,apr:3,jun:5,jul:6,aug:7,sep:8,sept:8,oct:9,nov:10,dec:11 };
+
+const STOP = new Set(['the','a','an','of','and','or','to','for','up','per','settlement','claim','claims','inc','llc','co','com',
+  'class','action','data','breach','payment','payments','refund','refunds','cash','credit','loan','loans','www','http','https',
+  'utm','source','email','2024','2025','2026','2023']);
+
+function words(s) {
+  return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, ' ').split(' ').filter(w => w && w.length > 2 && !STOP.has(w));
+}
+function jaccard(a, b) {
+  const A = new Set(a), B = new Set(b);
+  if (!A.size || !B.size) return 0;
+  let inter = 0; for (const x of A) if (B.has(x)) inter++;
+  return inter / (A.size + B.size - inter);
+}
+
+function parseDate(str) {
+  if (!str) return null;
+  const m = String(str).match(/([A-Za-z]+)\.?\s+(\d{1,2}),?\s+(\d{4})/);
+  if (m) { const mon = MONTHS[m[1].toLowerCase()]; if (mon != null) return new Date(Date.UTC(+m[3], mon, +m[2])); }
+  const m2 = String(str).match(/(\d{1,2})\/(\d{1,2})\/(\d{4})/);
+  if (m2) return new Date(Date.UTC(+m2[3], +m2[1] - 1, +m2[2]));
+  return null;
+}
+function ymd(d) { return d ? d.toISOString().slice(0, 10) : null; }
+
+function dollarsToCents(str) {
+  const nums = (String(str).match(/\$\s?([\d,]+(?:\.\d{2})?)/g) || []).map(x => Math.round(parseFloat(x.replace(/[$,\s]/g, '')) * 100)).filter(n => !isNaN(n));
+  return nums.length ? Math.max(...nums) : null;
+}
+
+function categorize(text) {
+  const t = text.toLowerCase();
+  if (/data breach|personal information|credit monitoring|notice about the/.test(t)) return 'data_breach';
+  if (/mortgage|loan|bank|overdraft|insurance|investment|kickback|fund|fee|financial|529|brokerage/.test(t)) return 'financial';
+  if (/employe|job applicant|wage|hour|payroll|background check|hired/.test(t)) return 'employment';
+  if (/purchase|bought|product|label|mislabel|inhaler|supplement|device|vehicle|repair|coupon|ticket|voucher/.test(t)) return 'product';
+  return 'other';
+}
+
+const isBullet = (l) => /^\s*[•\-\*·]/.test(l);
+// a heading = "<name> <dash> <payout>" or "<payout> per <thing>"
+function headingMatch(line) {
+  const l = line.trim();
+  if (!l || isBullet(l)) return null;
+  if (/^(deadline|more details|available benefits|hey|hi |don'?t|- dan|received|if you|bought|plus|check your)/i.test(l)) return null;
+  // Name – Up to $X  |  Name — $X Per Ticket  |  Name - $X
+  let m = l.match(/^(.{4,120}?)\s*[–—-]\s*((?:up to\s*)?\$[\d,]+(?:\.\d{2})?.*)$/i);
+  if (m && /settlement|claim|payment|refund|credit|cash|per\b|\$/i.test(l)) return { name: m[1].replace(/\s+settlement$/i, ' Settlement').trim(), payout: m[2].trim() };
+  return null;
+}
+
+/**
+ * Parse one Mode newsletter body → array of settlement objects.
+ * @param {string} body plain-text email body
+ * @param {object} meta { emailId, emailDate }
+ */
+function parseModeEmail(body, meta = {}) {
+  const text = String(body || '').replace(/\r\n/g, '\n');
+  const lines = text.split('\n');
+
+  // 1. collect headings with their line index + the block text until the next heading
+  const heads = [];
+  for (let i = 0; i < lines.length; i++) {
+    const h = headingMatch(lines[i]);
+    if (h) heads.push({ ...h, idx: i });
+  }
+  for (let k = 0; k < heads.length; k++) {
+    const start = heads[k].idx, end = k + 1 < heads.length ? heads[k + 1].idx : lines.length;
+    heads[k].block = lines.slice(start, end).join('\n');
+  }
+
+  // 2. collect all mode URLs with slugs
+  const urlRe = /https?:\/\/(?:www\.)?modeclassactionsdaily\.com\/([a-z0-9\-]+)\/?/gi;
+  const urls = [];
+  let u; while ((u = urlRe.exec(text))) urls.push({ url: u[0].replace(/[).,]+$/, ''), slug: u[1], used: false });
+
+  const out = [];
+  for (const h of heads) {
+    const block = h.block || '';
+    // deadline: first "Deadline:" in the block
+    const dl = (block.match(/deadline[:\s]+([^\n]+)/i) || [])[1];
+    const deadline = parseDate(dl);
+
+    // eligibility test = first line that reads like a membership test
+    const eLine = (block.split('\n').map(s => s.trim())
+      .find(s => /^(if you|bought|received|had |were |used |own(ed)?|purchased|applied|paid)/i.test(s) || (s.endsWith('?') && s.length < 200))) || null;
+
+    // best-matching URL by slug↔name word overlap (fixes Mode's swapped-URL quirk)
+    const nameW = words(h.name);
+    let best = null, bestScore = 0;
+    for (const cand of urls) {
+      const s = jaccard(nameW, words(cand.slug));
+      if (s > bestScore) { bestScore = s; best = cand; }
+    }
+    if (best && bestScore >= 0.12) best.used = true; else best = null;
+
+    const proofNeg = /no proof required|no receipts?|no claim form|no documentation|receipts? (are )?not required/i.test(block);
+    const proofPos = /documented losses|proof of|receipts? (are )?required|upload|submit documentation|itemized/i.test(block);
+    const noClaim = /no claim required|automatic payment|paid automatically|receive payments? automatically|won'?t have to (fill|file)|no action (is )?(required|needed)/i.test(block);
+
+    out.push({
+      name: h.name.replace(/\s+/g, ' ').trim(),
+      slug: best ? best.slug : (h.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 80)),
+      mode_url: best ? best.url : null,
+      payout_text: h.payout.replace(/\s+/g, ' ').trim(),
+      payout_max_cents: dollarsToCents(h.payout),
+      deadline: ymd(deadline),
+      proof_required: proofNeg ? false : (proofPos ? true : null),
+      no_claim_required: noClaim,
+      eligibility_question: eLine,
+      category: categorize(block),
+      source_email_id: meta.emailId || null,
+      source_email_date: meta.emailDate || null,
+      raw_block: block.trim().slice(0, 2000),
+    });
+  }
+  return out;
+}
+
+module.exports = { parseModeEmail, parseDate, dollarsToCents, categorize };
diff --git a/lib/openclaw-claim-filler.js b/lib/openclaw-claim-filler.js
new file mode 100644
index 0000000..801d933
--- /dev/null
+++ b/lib/openclaw-claim-filler.js
@@ -0,0 +1,71 @@
+// lib/openclaw-claim-filler.js — build an openclaw (real-Chrome portal-driver) job brief for a
+// settlement claim Steve has marked ELIGIBLE. It PREFILLS his known identity into the claim form
+// and STOPS at the penalty-of-perjury attestation + Submit button. It NEVER submits, and it is
+// only ever called for eligibility_state='eligible' rows.
+//
+// The brief is dropped into two places:
+//   • data/claim-fill-queue/<claim>.json   (app-local queue the dashboard reads)
+//   • ~/.claude/yolo-queue/pending-approval/  (so a Claude/portal-driver session picks it up)
+// The actual browser drive + the final human Submit happen via the portal-driver skill with Steve.
+const fs = require('fs');
+const path = require('path');
+const os = require('os');
+
+const LOCAL_Q = path.join(__dirname, '..', 'data', 'claim-fill-queue');
+const APPROVAL_Q = path.join(os.homedir(), '.claude', 'yolo-queue', 'pending-approval');
+
+// Hard guardrails copied into every brief so whoever/whatever runs it cannot miss them.
+const GUARDRAILS = [
+  'DO NOT click the final Submit / File Claim button. Stop at the review/attestation screen.',
+  'DO NOT check any "I declare under penalty of perjury / I certify" box — that is Steve\'s to check.',
+  'DO NOT invent, guess, or embellish eligibility facts (purchase, breach notice, employment, stock lot).',
+  'If the form asks for proof Steve has not supplied, stop and report what is missing.',
+  'Capture a screenshot of the fully-prefilled review screen and save its path in the result.',
+];
+
+function buildBrief(claim, profile) {
+  return {
+    kind: 'settlement-claim-fill',
+    generated_at: new Date().toISOString(),
+    claim: {
+      id: claim.id, name: claim.name, slug: claim.slug,
+      mode_url: claim.mode_url, admin_url: claim.admin_url || null,
+      payout_text: claim.payout_text, deadline: claim.deadline,
+      eligibility_question: claim.eligibility_question,
+    },
+    // Identity the agent MAY type into the form (from AbramsOS profile — never fabricated).
+    prefill: {
+      full_name: profile.full_name || null,
+      email: profile.email || null,
+      phone: profile.phone || null,
+      address: profile.address || null,
+    },
+    steps: [
+      `Open ${claim.mode_url || '(mode landing page)'} and follow through to the OFFICIAL settlement administrator's claim site.`,
+      'Locate the online claim form. If the settlement is "no claim required / automatic", STOP — nothing to file; just verify the mailing address is current.',
+      'Fill the form using ONLY the prefill identity above plus facts Steve has explicitly confirmed.',
+      'Choose the "no proof required" / flat cash option when eligible and available.',
+      'Advance to the final review screen, screenshot it, and STOP for Steve to attest + submit.',
+    ],
+    guardrails: GUARDRAILS,
+    engine: 'openclaw-real-chrome',
+    fill_state_on_success: 'prefilled_awaiting_submit',
+  };
+}
+
+function stage(claim, profile) {
+  if (claim.eligibility_state && claim.eligibility_state !== 'eligible') {
+    throw new Error(`refusing to stage fill for non-eligible claim (state=${claim.eligibility_state})`);
+  }
+  const brief = buildBrief(claim, profile || {});
+  fs.mkdirSync(LOCAL_Q, { recursive: true });
+  const local = path.join(LOCAL_Q, `${claim.id}.json`);
+  fs.writeFileSync(local, JSON.stringify(brief, null, 2));
+  try {
+    fs.mkdirSync(APPROVAL_Q, { recursive: true });
+    fs.writeFileSync(path.join(APPROVAL_Q, `settlement-claim-${claim.slug}.json`), JSON.stringify(brief, null, 2));
+  } catch (_e) { /* approval queue optional */ }
+  return { brief, local };
+}
+
+module.exports = { buildBrief, stage, GUARDRAILS, LOCAL_Q };
diff --git a/routes/settlements.js b/routes/settlements.js
new file mode 100644
index 0000000..85c7eaf
--- /dev/null
+++ b/routes/settlements.js
@@ -0,0 +1,64 @@
+// routes/settlements.js — class-action settlement claim tracker (Mode Class Actions feed).
+// Resilient: if the DB/table isn't available yet it falls back to the backfill JSON so the page
+// still renders. Mutations (eligibility, fill) require the DB. Auth is enforced upstream (requireAuth).
+const express = require('express');
+const fs = require('fs');
+const path = require('path');
+const db = require('../lib/db');
+const filler = require('../lib/openclaw-claim-filler');
+
+const router = express.Router();
+const USER = 'user_steve';
+const BACKFILL = path.join(__dirname, '..', 'data', 'mode-claims-backfill.json');
+
+async function loadRows() {
+  try {
+    const r = await db.query(
+      `SELECT * FROM settlement_claim WHERE user_id=$1
+        ORDER BY (fill_state='expired'), (eligibility_state<>'eligible'),
+                 (deadline IS NULL), deadline, payout_max_cents DESC NULLS LAST`, [USER]);
+    return { source: 'db', rows: r.rows };
+  } catch (_e) {
+    // DB offline / pre-migration → show the parsed backfill so the page isn't empty.
+    try {
+      const j = JSON.parse(fs.readFileSync(BACKFILL, 'utf8'));
+      return { source: 'backfill', rows: (j.settlements || []).map(s => ({ ...s, eligibility_state: 'unreviewed', fill_state: 'none', first_seen_at: j.generated_at })) };
+    } catch (_e2) { return { source: 'none', rows: [] }; }
+  }
+}
+
+router.get('/settlements', async (_req, res) => {
+  const { source, rows } = await loadRows();
+  res.render('settlements', { rows, source });
+});
+
+router.get('/api/settlements', async (_req, res) => {
+  const { source, rows } = await loadRows();
+  res.json({ source, count: rows.length, rows });
+});
+
+// Steve's eligibility decision — the ONLY thing that unlocks a fill.
+router.post('/api/settlements/:id/eligibility', async (req, res) => {
+  const state = String(req.body.state || '');
+  if (!['unreviewed', 'eligible', 'not_eligible', 'maybe'].includes(state)) return res.status(400).json({ error: 'bad state' });
+  try {
+    await db.query(`UPDATE settlement_claim SET eligibility_state=$1,updated_at=now() WHERE id=$2 AND user_id=$3`, [state, req.params.id, USER]);
+    res.json({ ok: true, state });
+  } catch (e) { res.status(503).json({ error: 'db offline: ' + e.message }); }
+});
+
+// Stage an openclaw fill brief (fills, never submits). Only for eligible rows.
+router.post('/api/settlements/:id/fill', async (req, res) => {
+  try {
+    const r = await db.query(`SELECT * FROM settlement_claim WHERE id=$1 AND user_id=$2`, [req.params.id, USER]);
+    if (!r.rows.length) return res.status(404).json({ error: 'not found' });
+    const claim = r.rows[0];
+    if (claim.eligibility_state !== 'eligible') return res.status(409).json({ error: 'mark eligible first (never file where you are not a class member)' });
+    const prof = await db.query(`SELECT full_name,email,phone,address FROM person WHERE user_id=$1 AND relation='self' LIMIT 1`, [USER]).then(x => x.rows[0] || {}).catch(() => ({}));
+    const { local } = filler.stage(claim, prof);
+    await db.query(`UPDATE settlement_claim SET fill_state='queued',updated_at=now() WHERE id=$1`, [claim.id]);
+    res.json({ ok: true, staged: local, note: 'openclaw brief staged — it will prefill and STOP before submit for your review.' });
+  } catch (e) { res.status(503).json({ error: 'db offline: ' + e.message }); }
+});
+
+module.exports = router;
diff --git a/scripts/backfill-mode-claims.js b/scripts/backfill-mode-claims.js
new file mode 100644
index 0000000..e55adc8
--- /dev/null
+++ b/scripts/backfill-mode-claims.js
@@ -0,0 +1,46 @@
+#!/usr/bin/env node
+// Parse the Mode newsletter corpus → distinct, deduped, ranked settlement list.
+// DB-free: reads data/mode-emails-raw.js, writes data/mode-claims-backfill.json.
+// Usage: node scripts/backfill-mode-claims.js [YYYY-MM-DD as "today"]
+const fs = require('fs');
+const path = require('path');
+const { parseModeEmail } = require('../lib/mode-claims');
+
+const emails = require('../data/mode-emails-raw.js');
+const TODAY = process.argv[2] || new Date().toISOString().slice(0, 10);
+
+const bySlug = new Map();
+for (const e of emails) {
+  for (const s of parseModeEmail(e.body, { emailId: e.id, emailDate: e.date })) {
+    const prev = bySlug.get(s.slug);
+    // keep the earliest-seen instance but prefer one with a deadline + richer payout
+    if (!prev || (!prev.deadline && s.deadline) || (s.payout_max_cents || 0) > (prev.payout_max_cents || 0)) {
+      bySlug.set(s.slug, { ...prev, ...s, seen_count: (prev?.seen_count || 0) + 1 });
+    } else {
+      prev.seen_count = (prev.seen_count || 0) + 1;
+    }
+  }
+}
+
+const all = [...bySlug.values()];
+const open = all.filter(s => !s.deadline || s.deadline >= TODAY);
+const expired = all.filter(s => s.deadline && s.deadline < TODAY);
+
+// rank: no-claim-required (free money) first, then by payout ceiling desc, then soonest deadline
+const rank = (a, b) =>
+  (b.no_claim_required - a.no_claim_required) ||
+  ((b.payout_max_cents || 0) - (a.payout_max_cents || 0)) ||
+  String(a.deadline || '9999').localeCompare(String(b.deadline || '9999'));
+open.sort(rank);
+
+const out = { generated_at: new Date().toISOString(), today: TODAY, corpus_emails: emails.length,
+  distinct_settlements: all.length, open: open.length, expired: expired.length, settlements: open };
+fs.writeFileSync(path.join(__dirname, '..', 'data', 'mode-claims-backfill.json'), JSON.stringify(out, null, 2));
+
+const money = c => c == null ? '—' : '$' + (c / 100).toLocaleString('en-US');
+console.log(`Corpus ${emails.length} emails → ${all.length} distinct settlements  |  OPEN ${open.length}  EXPIRED ${expired.length}\n`);
+console.log('OPEN (deadline ≥ ' + TODAY + '), ranked:');
+for (const s of open) {
+  const tag = s.no_claim_required ? 'AUTO ' : (s.proof_required === false ? 'NOPROOF ' : (s.proof_required ? 'PROOF ' : ''));
+  console.log(`  [${(s.deadline||'—')}] ${money(s.payout_max_cents).padStart(9)}  ${tag}${s.name}`);
+}
diff --git a/scripts/ingest-mode-claims.js b/scripts/ingest-mode-claims.js
new file mode 100644
index 0000000..4b8e3dd
--- /dev/null
+++ b/scripts/ingest-mode-claims.js
@@ -0,0 +1,62 @@
+#!/usr/bin/env node
+// Native ingester: pull Mode Class Actions emails via the stored Gmail connector, parse them
+// with lib/mode-claims, and upsert distinct settlements into settlement_claim.
+// Idempotent (UNIQUE user_id+slug). $0 (Gmail API + local parse). No submits, no browser.
+//
+// Usage: node scripts/ingest-mode-claims.js [--days 180]
+const db = require('../lib/db');
+const fetcher = require('../lib/gmail-fetcher');
+const { decrypt } = require('../lib/crypto');
+const { parseModeEmail } = require('../lib/mode-claims');
+const { id } = require('../lib/ids');
+
+const USER = process.env.ABRAMSOS_USER_ID || 'user_steve';
+const MODE_FROM = 'daniel@mail.modemobile.com';
+const days = (() => { const i = process.argv.indexOf('--days'); return i > -1 ? parseInt(process.argv[i + 1], 10) : 180; })();
+
+async function gmailRefreshToken() {
+  // Prefer a Gmail connector on the steve-personal address (that's where Mode lands).
+  const r = await db.query(
+    `SELECT refresh_token_enc FROM connector
+      WHERE user_id=$1 AND provider IN ('google','gmail') AND status='connected'
+      ORDER BY updated_at DESC LIMIT 1`, [USER]);
+  if (!r.rows.length) throw new Error('No connected Gmail connector — authorize one at /connectors first.');
+  return decrypt(r.rows[0].refresh_token_enc);
+}
+
+async function main() {
+  const token = await gmailRefreshToken();
+  const q = `from:${MODE_FROM} newer_than:${days}d`;
+  const ids = await fetcher.listReceiptIds(token, { query: q, max: 400 });
+  console.log(`[mode-ingest] ${ids.length} messages match "${q}"`);
+  let seen = 0, upserts = 0;
+  for (const mid of ids) {
+    const msg = await fetcher.getMessage(token, mid);
+    const s = fetcher.summarize(msg);                 // { from, subject, date, text, html }
+    const body = s.text || s.html || '';
+    const emailDate = new Date(parseInt(msg.internalDate, 10)).toISOString();
+    for (const c of parseModeEmail(body, { emailId: mid, emailDate })) {
+      seen++;
+      const r = await db.query(
+        `INSERT INTO settlement_claim
+           (id,user_id,slug,name,mode_url,payout_text,payout_max_cents,deadline,proof_required,
+            no_claim_required,eligibility_question,category,source_email_id,source_email_date,raw_block)
+         VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)
+         ON CONFLICT (user_id,slug) DO UPDATE SET
+           mode_url=COALESCE(EXCLUDED.mode_url,settlement_claim.mode_url),
+           deadline=COALESCE(EXCLUDED.deadline,settlement_claim.deadline),
+           payout_max_cents=GREATEST(COALESCE(EXCLUDED.payout_max_cents,0),COALESCE(settlement_claim.payout_max_cents,0)),
+           updated_at=now()
+         RETURNING (xmax=0) AS inserted`,
+        [id('claim'), USER, c.slug, c.name, c.mode_url, c.payout_text, c.payout_max_cents, c.deadline,
+         c.proof_required, c.no_claim_required, c.eligibility_question, c.category, c.source_email_id, c.source_email_date, c.raw_block]);
+      if (r.rows[0]?.inserted) upserts++;
+    }
+  }
+  // auto-expire past-deadline rows that were never acted on
+  await db.query(`UPDATE settlement_claim SET fill_state='expired',updated_at=now()
+                   WHERE user_id=$1 AND deadline < current_date AND fill_state IN ('none','queued')`, [USER]);
+  console.log(`[mode-ingest] parsed ${seen} settlement blocks; ${upserts} new rows inserted.`);
+  process.exit(0);
+}
+main().catch(e => { console.error('[mode-ingest] ERROR', e.message); process.exit(1); });
diff --git a/scripts/mode-claims-watch.js b/scripts/mode-claims-watch.js
new file mode 100644
index 0000000..6386c15
--- /dev/null
+++ b/scripts/mode-claims-watch.js
@@ -0,0 +1,52 @@
+#!/usr/bin/env node
+// "As soon as email lands, run it" — the Mode claims watcher.
+// Polls (via launchd, every 15 min) for NEW Mode messages, runs the ingester when any arrive,
+// and stages an openclaw fill brief for any settlement Steve has already marked ELIGIBLE that
+// isn't yet queued. Never submits. Guard-fails cleanly if DB/Gmail connector is unavailable.
+//
+// Usage: node scripts/mode-claims-watch.js
+const fs = require('fs');
+const path = require('path');
+const { execFileSync } = require('child_process');
+
+const STATE = path.join(__dirname, '..', 'data', 'mode-claims-watch-state.json');
+const LOG = path.join(__dirname, '..', 'logs', 'mode-claims-watch.log');
+const log = (m) => { const line = `${new Date().toISOString()} ${m}\n`; try { fs.appendFileSync(LOG, line); } catch (_) {} process.stdout.write(line); };
+
+async function main() {
+  let db, fetcher, decrypt, filler;
+  try { db = require('../lib/db'); fetcher = require('../lib/gmail-fetcher'); ({ decrypt } = require('../lib/crypto')); filler = require('../lib/openclaw-claim-filler'); }
+  catch (e) { return log('SKIP module load: ' + e.message); }
+
+  const USER = process.env.ABRAMSOS_USER_ID || 'user_steve';
+  let token;
+  try {
+    const r = await db.query(`SELECT refresh_token_enc FROM connector WHERE user_id=$1 AND provider IN ('google','gmail') AND status='connected' ORDER BY updated_at DESC LIMIT 1`, [USER]);
+    if (!r.rows.length) return log('SKIP: no connected Gmail connector yet.');
+    token = decrypt(r.rows[0].refresh_token_enc);
+  } catch (e) { return log('SKIP db/connector: ' + e.message); }
+
+  // any NEW Mode mail since last check?
+  const ids = await fetcher.listReceiptIds(token, { query: 'from:daniel@mail.modemobile.com newer_than:2d', max: 10 });
+  const state = fs.existsSync(STATE) ? JSON.parse(fs.readFileSync(STATE, 'utf8')) : { seen: [] };
+  const fresh = ids.filter(x => !state.seen.includes(x));
+  if (fresh.length) {
+    log(`NEW ${fresh.length} Mode message(s) — ingesting.`);
+    try { execFileSync(process.execPath, [path.join(__dirname, 'ingest-mode-claims.js'), '--days', '14'], { stdio: 'inherit' }); }
+    catch (e) { log('ingest error: ' + e.message); }
+  } else { log('no new Mode mail.'); }
+  state.seen = ids; fs.writeFileSync(STATE, JSON.stringify(state));
+
+  // stage fills for eligible-but-unqueued claims (Steve flips eligibility in the dashboard)
+  try {
+    const elig = await db.query(`SELECT * FROM settlement_claim WHERE user_id=$1 AND eligibility_state='eligible' AND fill_state='none' AND (deadline IS NULL OR deadline>=current_date)`, [USER]);
+    const prof = await db.query(`SELECT full_name,email,phone,address FROM person WHERE user_id=$1 AND relation='self' LIMIT 1`, [USER]).then(r => r.rows[0] || {}).catch(() => ({}));
+    for (const c of elig.rows) {
+      filler.stage(c, prof);
+      await db.query(`UPDATE settlement_claim SET fill_state='queued',updated_at=now() WHERE id=$1`, [c.id]);
+      log(`STAGED openclaw fill brief (fill-not-submit) for: ${c.name}`);
+    }
+  } catch (e) { log('stage error: ' + e.message); }
+  process.exit(0);
+}
+main().catch(e => { log('FATAL ' + e.message); process.exit(1); });
diff --git a/server.js b/server.js
index bc752cd..ca8a5ba 100644
--- a/server.js
+++ b/server.js
@@ -19,6 +19,7 @@ const plaidRouter = require('./routes/plaid');
 const documentsRouter = require('./routes/documents');
 const remindersRouter = require('./routes/reminders');
 const claimsRouter = require('./routes/claims');
+const settlementsRouter = require('./routes/settlements');
 const auditRouter = require('./routes/audit');
 const recallsRouter = require('./routes/recalls');
 const uploadRouter = require('./routes/upload');
@@ -97,6 +98,7 @@ app.use(plaidRouter);               // /api/plaid/*
 app.use(documentsRouter);           // /api/documents, /api/documents/:id/parse
 app.use(remindersRouter);           // /api/reminders/upcoming, regenerate, dismiss
 app.use(claimsRouter);              // /api/claims, /api/claims/from-reminder/:id
+app.use(settlementsRouter);         // /settlements, /api/settlements (Mode class-action feed)
 app.use(auditRouter);               // /audit, /api/audit/events
 app.use(recallsRouter);             // /recalls, /api/recalls
 app.use(uploadRouter);              // /api/upload/csv
diff --git a/views/settlements.ejs b/views/settlements.ejs
new file mode 100644
index 0000000..d467fe6
--- /dev/null
+++ b/views/settlements.ejs
@@ -0,0 +1,64 @@
+<%- include('partials/header', { title: 'Settlements' }) %>
+<section class="page-head">
+  <h1>Settlement Claims</h1>
+  <p class="subtle" style="margin:0;color:var(--text-dim);font-size:13px">
+    Auto-collected from the <strong>Mode Class Actions</strong> feed. Nothing is filed automatically.
+    Mark <em>Eligible</em> only for settlements you're truly a class member of — then openclaw prefills the
+    form and <strong>stops at the perjury attestation for you to submit</strong>.
+    <% if (source !== 'db') { %><span style="color:#e0a030">· showing parsed backfill (DB offline — mutations disabled)</span><% } %>
+  </p>
+</section>
+<section class="glass" style="padding:0;overflow:auto">
+  <table style="width:100%;border-collapse:collapse;font-size:13px">
+    <thead><tr style="text-align:left;color:var(--text-dim);border-bottom:1px solid rgba(255,255,255,.08)">
+      <th style="padding:10px 12px">Settlement</th><th>Max</th><th>Deadline</th><th>Proof</th>
+      <th>Eligibility test</th><th>You</th><th>Fill</th>
+    </tr></thead>
+    <tbody>
+    <% rows.forEach(function(s){
+       var money = s.payout_max_cents==null ? '—' : '$'+(s.payout_max_cents/100).toLocaleString('en-US');
+       var proof = s.no_claim_required ? 'AUTO' : (s.proof_required===false ? 'no proof' : (s.proof_required ? 'proof' : '?'));
+       var es = s.eligibility_state||'unreviewed';
+    %>
+      <tr data-id="<%= s.id %>" style="border-bottom:1px solid rgba(255,255,255,.05)<%= s.fill_state==='expired'?';opacity:.45':'' %>">
+        <td style="padding:10px 12px">
+          <% if (s.mode_url){ %><a href="<%= s.mode_url %>" target="_blank" rel="noopener noreferrer"><%= s.name %> ↗</a><% } else { %><%= s.name %><% } %>
+          <div style="color:var(--text-dim);font-size:11px"><%= s.category||'' %> · seen <%= (s.first_seen_at||'').toString().slice(0,10) %></div>
+        </td>
+        <td><strong><%= money %></strong></td>
+        <td><%= s.deadline||'—' %></td>
+        <td><span class="chip" style="font-size:11px"><%= proof %></span></td>
+        <td style="max-width:320px;color:var(--text-dim)"><%= s.eligibility_question||'' %></td>
+        <td>
+          <select class="elig" data-id="<%= s.id %>" style="background:transparent;color:inherit;border:1px solid rgba(255,255,255,.15);border-radius:6px;padding:3px">
+            <% ['unreviewed','eligible','maybe','not_eligible'].forEach(function(o){ %>
+              <option value="<%= o %>" <%= es===o?'selected':'' %>><%= o %></option>
+            <% }) %>
+          </select>
+        </td>
+        <td>
+          <% if (s.fill_state==='queued'){ %><span class="chip">queued</span>
+          <% } else if (s.fill_state==='prefilled_awaiting_submit'){ %><span class="chip" style="color:#e0a030">review &amp; submit</span>
+          <% } else { %><button class="button fill" data-id="<%= s.id %>" <%= es==='eligible'?'':'disabled' %>>Prefill via openclaw</button><% } %>
+        </td>
+      </tr>
+    <% }) %>
+    </tbody>
+  </table>
+</section>
+<script>
+document.querySelectorAll('.elig').forEach(function(sel){
+  sel.addEventListener('change', function(){
+    fetch('/api/settlements/'+this.dataset.id+'/eligibility',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({state:this.value})})
+      .then(r=>r.json()).then(()=>location.reload());
+  });
+});
+document.querySelectorAll('.fill').forEach(function(btn){
+  btn.addEventListener('click', function(){
+    if(!confirm('Stage an openclaw job to PREFILL this claim? It will stop before submitting.')) return;
+    fetch('/api/settlements/'+this.dataset.id+'/fill',{method:'POST'}).then(r=>r.json())
+      .then(j=>{ alert(j.note||j.error||'staged'); location.reload(); });
+  });
+});
+</script>
+<%- include('partials/footer') %>

← e62bbd3 auto-data-snapshot: 2026-08-07T19:03:19 (2 data files) — dat  ·  back to AbramsOS  ·  AbramsOS claims: apply migration 0016, fix parser (Estimated 6fb5271 →