[object Object]

← back to Discontinued Agent

discontinued-agent: George→FileMaker vendor-discontinued pipeline (skill + 10s poller daemon)

637b1f35a82ee4f1a7c3e728be4466ee0bd55c9b · 2026-07-08 08:33:48 -0700 · Steve Abrams

Watches info@ Gmail for vendor replies to DW's 'Priority Request - Samples
over 10 days old' follow-ups. On explicit discontinuation, stages/commits
Date Discontinued + closes open sample orders (Date WP Sample Sent) in
FileMaker WALLPAPER/REPORT ON SAMPLES ORDERED. Dry-run by default; requires
DISCO_AUTOCOMMIT=1 to write. Ambiguous/sensitive replies (relationship-ended,
brand-attribution, no-sample, backorder) queue to pending-approval. Reuses
george-gmail + filemaker-mcp creds (no hardcoded secrets).

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

Files touched

Diff

commit 637b1f35a82ee4f1a7c3e728be4466ee0bd55c9b
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Jul 8 08:33:48 2026 -0700

    discontinued-agent: George→FileMaker vendor-discontinued pipeline (skill + 10s poller daemon)
    
    Watches info@ Gmail for vendor replies to DW's 'Priority Request - Samples
    over 10 days old' follow-ups. On explicit discontinuation, stages/commits
    Date Discontinued + closes open sample orders (Date WP Sample Sent) in
    FileMaker WALLPAPER/REPORT ON SAMPLES ORDERED. Dry-run by default; requires
    DISCO_AUTOCOMMIT=1 to write. Ambiguous/sensitive replies (relationship-ended,
    brand-attribution, no-sample, backorder) queue to pending-approval. Reuses
    george-gmail + filemaker-mcp creds (no hardcoded secrets).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 .env.example                       |  31 ++++
 .gitignore                         |  15 ++
 README.md                          | 115 +++++++++++++
 com.steve.discontinued-agent.plist |  41 +++++
 lib/filemaker.js                   | 100 +++++++++++
 lib/george.js                      |  79 +++++++++
 lib/parse.js                       | 182 ++++++++++++++++++++
 package.json                       |  18 ++
 poller.js                          | 339 +++++++++++++++++++++++++++++++++++++
 9 files changed, 920 insertions(+)

diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..1a41f68
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,31 @@
+# discontinued-agent config — copy to .env and adjust.
+# George + FileMaker are local / self-hosted = $0 per call.
+
+# ---- George (Gmail HTTP agent) ----
+# Health endpoint needs no auth; other routes need HTTP Basic (admin:<pw>).
+GEORGE_URL=http://127.0.0.1:9850
+# If your George instance requires basic auth on /api/messages etc., set this to
+# the base64 of "admin:<pw>" (same value george-mcp reads from the dw-agents keychain).
+# Leave blank to try unauthenticated first, then fall back to the keychain lookup.
+GEORGE_BASIC_AUTH=
+# Gmail account key. "info" = info@designerwallcoverings.com (the follow-up inbox).
+GEORGE_ACCOUNT=info
+# Gmail search that selects the follow-up threads to scan.
+GMAIL_SEARCH=subject:"Priority Request - Samples over 10 days old" newer_than:60d
+
+# ---- FileMaker (Cloud Data API, via the filemaker-mcp fm-client) ----
+# Path to the filemaker-mcp project whose src/fm-client.js + .env we reuse.
+# We DO NOT hardcode FM creds — they are read from this project's own .env
+# (FM_CLOUD_HOST / FM_CLARIS_EMAIL / FM_CLARIS_PASSWORD / FM_COGNITO_*).
+FM_MCP_DIR=/Users/macstudio3/Projects/filemaker-mcp
+FM_DATABASE=WALLPAPER
+FM_LAYOUT=REPORT ON SAMPLES ORDERED
+
+# ---- Behaviour ----
+# Loop interval in seconds (daemon mode). Default 10.
+POLL_INTERVAL_SEC=10
+# HARD SAFETY GATE. Unless this is exactly "1", the daemon only stages + queues;
+# it NEVER commits a FileMaker write. Dry-run diffs are always logged first.
+DISCO_AUTOCOMMIT=0
+# Where ambiguous/sensitive cases get parked for Steve to review.
+QUEUE_DIR=/Users/macstudio3/.claude/yolo-queue/pending-approval
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..06906f8
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,15 @@
+node_modules/
+.env
+.env.*
+!.env.example
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
+
+# runtime state — audit ledger + processed-thread state stay local
+data/*.jsonl
+data/*.json
+data/*.log
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..add2c12
--- /dev/null
+++ b/README.md
@@ -0,0 +1,115 @@
+# discontinued-agent
+
+Watches the **info@designerwallcoverings.com** Gmail inbox for vendor replies to
+DW's *"Priority Request - Samples over 10 days old - Designer Wallcoverings"*
+sample-follow-up emails. When a vendor **explicitly** says a product is
+discontinued, it stamps the wallpaper record discontinued in FileMaker and closes
+the open sample order — so the >10-day follow-up system stops re-asking.
+
+Ships as **both** a standing poller daemon (this project) and a Claude skill
+(`~/.claude/skills/discontinued-agent/SKILL.md`).
+
+**Cost:** George (Gmail HTTP agent) + FileMaker Cloud are local / self-hosted =
+**$0 per call**.
+
+---
+
+## How it works
+
+1. **Read** — polls George (`http://127.0.0.1:9850`, account `info`) for threads
+   whose subject contains *"Priority Request - Samples over 10 days old"*.
+2. **Group** — groups messages by Gmail `threadId` (oldest → newest). Each thread
+   has DW **outbound** message(s) (from `info@…`) listing SKUs under a
+   *"Manufacturer Number"* block, plus **vendor reply** message(s).
+3. **Extract** — pulls the mfr#(s) from the DW outbound's *Manufacturer Number*
+   block (each item line is `<Pattern Name> <MFR#>`, e.g. `Shooting Star CM104-2180`).
+   The address/phone footer is explicitly excluded.
+4. **Classify** the newest vendor reply:
+   - **COMMIT** — reply literally contains *discontinued* / *no longer available* /
+     *can not order … discontinued*, **and** at least one clean mfr# was extracted.
+   - **QUEUE** — ambiguous/sensitive: *no sample available*, *backordered / no
+     restock*, relationship-ended, brand-attribution, legal, or "discontinued but
+     no mfr# mapped". A memo is written to
+     `~/.claude/yolo-queue/pending-approval/` and **no FileMaker write** happens.
+   - **SKIP** — nothing actionable.
+5. **Write** (COMMIT only) against FileMaker `WALLPAPER` /
+   `REPORT ON SAMPLES ORDERED`, for every row matching the mfr# via `Mfr Pattern`
+   (FileMaker word-match — `CM104-2180` matches stored `Shooting Star CM104-2180`):
+   - **WRITE 1 — discontinue:** set `Date Discontinued` = today (MM/DD/YYYY) on
+     every matching row whose `Date Discontinued` is currently blank (skip dated rows).
+   - **WRITE 2 — close open order:** set `Date WP Sample Sent` = today on rows that
+     are OPEN sample orders (non-empty `account`/`Clients::Company` **and** a
+     `Date Sample Request printed for vendor` **and** a blank `Date WP Sample Sent`).
+     This blank field is what the >10-day follow-up query keys on; stamping it
+     removes the item from future blasts.
+6. **Audit** — every action (committed, staged, queued) is appended to
+   `data/ledger.jsonl`; a processed-state file `data/processed-threads.json`
+   (keyed by `threadId` + a hash of the vendor message) prevents double-processing.
+
+## SAFETY GATE
+
+By default `DISCO_AUTOCOMMIT=0` → the daemon **only stages** (dry-run diffs) and
+**queues**; it never commits a FileMaker write. Set `DISCO_AUTOCOMMIT=1` in `.env`
+to actually commit. Dry-run diffs are always computed + logged first.
+
+---
+
+## Setup
+
+```sh
+cd ~/Projects/discontinued-agent
+cp .env.example .env      # then adjust
+```
+
+Key `.env` values:
+
+| var | meaning |
+|-----|---------|
+| `GEORGE_URL` | `http://127.0.0.1:9850` |
+| `GEORGE_BASIC_AUTH` | base64 of `admin:<pw>` for George's `/api/*`. On this box the pw is empty → `YWRtaW46` (`admin:`). |
+| `GEORGE_ACCOUNT` | `info` |
+| `FM_MCP_DIR` | `~/Projects/filemaker-mcp` — its `src/fm-client.js` + `.env` are reused for FM auth (**no creds hardcoded here**). |
+| `FM_DATABASE` / `FM_LAYOUT` | `WALLPAPER` / `REPORT ON SAMPLES ORDERED` |
+| `POLL_INTERVAL_SEC` | daemon loop interval (default `10`) |
+| `DISCO_AUTOCOMMIT` | `1` to commit; anything else = stage + queue only |
+
+## Run on-demand (single scan, always safe to preview)
+
+```sh
+node --env-file=.env poller.js --once     # one scan, then exit
+# or: npm run once
+```
+
+## Run as the daemon (loops every 10s)
+
+The poller loops internally; launchd just keeps it alive.
+
+```sh
+# start (bootstrap the LaunchAgent):
+launchctl bootstrap gui/$(id -u) ~/Projects/discontinued-agent/com.steve.discontinued-agent.plist
+
+# stop:
+launchctl bootout gui/$(id -u)/com.steve.discontinued-agent
+
+# status / restart:
+launchctl print gui/$(id -u)/com.steve.discontinued-agent | head
+launchctl kickstart -k gui/$(id -u)/com.steve.discontinued-agent
+
+# logs:
+tail -f data/agent.out.log
+```
+
+> Note: the plist lives in this repo. To have launchd load it you must have the
+> file at a stable path — it references this project directory directly, so it
+> can be bootstrapped straight from the repo (no copy into `~/Library/LaunchAgents`
+> required, though you may symlink it there if you prefer).
+
+## Files
+
+- `poller.js` — the daemon (single-scan `--once`, or infinite loop).
+- `lib/george.js` — George Gmail HTTP client (read-only here).
+- `lib/filemaker.js` — FM Cloud Data API wrapper (imports filemaker-mcp's client + creds).
+- `lib/parse.js` — thread parse, mfr# extraction, gating classification.
+- `data/ledger.jsonl` — append-only audit log (gitignored).
+- `data/processed-threads.json` — dedupe state (gitignored).
+- `com.steve.discontinued-agent.plist` — KeepAlive LaunchAgent.
diff --git a/com.steve.discontinued-agent.plist b/com.steve.discontinued-agent.plist
new file mode 100644
index 0000000..50d754c
--- /dev/null
+++ b/com.steve.discontinued-agent.plist
@@ -0,0 +1,41 @@
+<?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.discontinued-agent</string>
+
+  <!-- Runs the poller, which internally loops every POLL_INTERVAL_SEC (default 10s). -->
+  <key>ProgramArguments</key>
+  <array>
+    <string>/opt/homebrew/bin/node</string>
+    <string>--env-file=/Users/macstudio3/Projects/discontinued-agent/.env</string>
+    <string>/Users/macstudio3/Projects/discontinued-agent/poller.js</string>
+  </array>
+
+  <key>WorkingDirectory</key>
+  <string>/Users/macstudio3/Projects/discontinued-agent</string>
+
+  <!-- KeepAlive: respawn if it ever exits. -->
+  <key>KeepAlive</key>
+  <true/>
+  <key>RunAtLoad</key>
+  <true/>
+
+  <!-- Throttle respawn so a crash-loop doesn't hammer George / FileMaker. -->
+  <key>ThrottleInterval</key>
+  <integer>30</integer>
+
+  <key>StandardOutPath</key>
+  <string>/Users/macstudio3/Projects/discontinued-agent/data/agent.out.log</string>
+  <key>StandardErrorPath</key>
+  <string>/Users/macstudio3/Projects/discontinued-agent/data/agent.err.log</string>
+
+  <key>EnvironmentVariables</key>
+  <dict>
+    <key>PATH</key>
+    <string>/opt/homebrew/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
+  </dict>
+</dict>
+</plist>
diff --git a/lib/filemaker.js b/lib/filemaker.js
new file mode 100644
index 0000000..62e63fd
--- /dev/null
+++ b/lib/filemaker.js
@@ -0,0 +1,100 @@
+// FileMaker Cloud Data API client for the discontinued-agent.
+//
+// We DO NOT reimplement Claris/Cognito SRP or hardcode any credentials.
+// Instead we dynamically import the already-working fm-client from the
+// filemaker-mcp project (FM_MCP_DIR) and load ITS .env for the host + creds.
+// That project holds the FM Cloud credentials (FM_CLOUD_HOST / FM_CLARIS_* /
+// FM_COGNITO_*) and the amazon-cognito-identity-js dependency.
+//
+// Cost: FileMaker Cloud Data API is self-hosted / already-paid = $0 per call.
+
+import { readFileSync } from 'node:fs';
+import { join } from 'node:path';
+import { pathToFileURL } from 'node:url';
+
+const FM_MCP_DIR = process.env.FM_MCP_DIR || '/Users/macstudio3/Projects/filemaker-mcp';
+export const FM_DATABASE = process.env.FM_DATABASE || 'WALLPAPER';
+export const FM_LAYOUT = process.env.FM_LAYOUT || 'REPORT ON SAMPLES ORDERED';
+
+// Field names on the REPORT ON SAMPLES ORDERED layout (verified via field metadata).
+export const FIELDS = {
+  mfrPattern: 'Mfr Pattern',
+  dateDiscontinued: 'Date Discontinued',
+  dateSampleSent: 'Date WP Sample Sent',
+  account: 'account',
+  company: 'Clients::Company',
+  requestPrinted: 'Date Sample Request printed for vendor',
+};
+
+// Load the filemaker-mcp .env into process.env WITHOUT clobbering anything we
+// already set (our own env wins). Minimal parser: KEY=VALUE, ignores comments.
+function loadFmEnv() {
+  const envPath = join(FM_MCP_DIR, '.env');
+  let raw;
+  try {
+    raw = readFileSync(envPath, 'utf8');
+  } catch {
+    throw new Error(`filemaker-mcp .env not found at ${envPath} — cannot resolve FM credentials.`);
+  }
+  for (const line of raw.split('\n')) {
+    const m = line.match(/^([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
+    if (!m) continue;
+    const k = m[1];
+    let v = m[2];
+    // strip surrounding single/double quotes if present
+    if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
+      v = v.slice(1, -1);
+    }
+    if (process.env[k] === undefined) process.env[k] = v;
+  }
+}
+
+let _client;
+async function client() {
+  if (_client) return _client;
+  loadFmEnv();
+  const clientPath = pathToFileURL(join(FM_MCP_DIR, 'src', 'fm-client.js')).href;
+  _client = await import(clientPath);
+  return _client;
+}
+
+// Today's date in FileMaker MM/DD/YYYY format.
+export function fmToday(d = new Date()) {
+  const mm = String(d.getMonth() + 1).padStart(2, '0');
+  const dd = String(d.getDate()).padStart(2, '0');
+  const yyyy = d.getFullYear();
+  return `${mm}/${dd}/${yyyy}`;
+}
+
+// Prove auth + a Data API session.
+export async function ping() {
+  const c = await client();
+  return c.ping();
+}
+
+// Find all rows whose `Mfr Pattern` word-matches the given mfr#.
+// FileMaker _find is a word/substring match on that field.
+export async function findByMfrPattern(mfr, { limit = 200 } = {}) {
+  const c = await client();
+  const query = { [FIELDS.mfrPattern]: mfr };
+  try {
+    const { records } = await c.findRecords(FM_DATABASE, FM_LAYOUT, query, { limit });
+    return records || [];
+  } catch (e) {
+    // FileMaker returns [401] "No records match the request" — treat as empty.
+    if (String(e.fmCode) === '401') return [];
+    throw e;
+  }
+}
+
+// Update a single record's fields. dryRun:true returns the before->after diff
+// WITHOUT committing; dryRun:false commits.
+export async function updateRecord(recordId, fieldData, { dryRun = true } = {}) {
+  const c = await client();
+  return c.updateRecord(FM_DATABASE, FM_LAYOUT, recordId, fieldData, { dryRun });
+}
+
+// Convenience: pull fieldData + recordId from a Data API record object.
+export function recRow(rec) {
+  return { recordId: rec.recordId, fields: rec.fieldData || {} };
+}
diff --git a/lib/george.js b/lib/george.js
new file mode 100644
index 0000000..75008f4
--- /dev/null
+++ b/lib/george.js
@@ -0,0 +1,79 @@
+// George (DW Gmail HTTP agent) client — read-only usage here.
+// George runs locally on http://127.0.0.1:9850. Health needs no auth; the
+// data routes may require HTTP Basic (admin:<pw>). We reuse the same keychain
+// item george-mcp uses (service "dw-agents", account "admin") when
+// GEORGE_BASIC_AUTH is not supplied in the env.
+//
+// Cost: George is local / self-hosted = $0 per call.
+
+import { execSync } from 'node:child_process';
+
+const BASE_URL = (process.env.GEORGE_URL || 'http://127.0.0.1:9850').replace(/\/$/, '');
+const ACCOUNT = process.env.GEORGE_ACCOUNT || 'info';
+
+function keychainAuth() {
+  try {
+    const pw = execSync('security find-generic-password -s dw-agents -a admin -w', {
+      encoding: 'utf-8',
+    }).trim();
+    return Buffer.from(`admin:${pw}`).toString('base64');
+  } catch {
+    return null;
+  }
+}
+
+let _auth; // memoized: undefined = not resolved, null = none
+function basicAuth() {
+  if (_auth !== undefined) return _auth;
+  _auth = process.env.GEORGE_BASIC_AUTH || keychainAuth() || null;
+  return _auth;
+}
+
+async function george(path, { query } = {}) {
+  const url = new URL(BASE_URL + path);
+  if (query) {
+    for (const [k, v] of Object.entries(query)) {
+      if (v !== undefined && v !== null) url.searchParams.set(k, String(v));
+    }
+  }
+  const headers = {};
+  const auth = basicAuth();
+  if (auth) headers.Authorization = `Basic ${auth}`;
+  const res = await fetch(url, { headers });
+  const text = await res.text();
+  let data;
+  try {
+    data = JSON.parse(text);
+  } catch {
+    data = { raw: text };
+  }
+  if (!res.ok) {
+    const err = new Error(`George GET ${path} -> ${res.status} ${res.statusText}: ${text.slice(0, 200)}`);
+    err.status = res.status;
+    throw err;
+  }
+  return data;
+}
+
+export async function health() {
+  // Public, unauthenticated liveness route. (/api/health requires Basic auth.)
+  const res = await fetch(`${BASE_URL}/health`);
+  if (!res.ok) throw new Error(`George health ${res.status}`);
+  return res.json().catch(() => ({ status: 'ok' }));
+}
+
+// List messages matching a Gmail search query. Returns [{id,threadId,subject,from,to,date,snippet}].
+export async function listMessages(query, { maxResults = 50 } = {}) {
+  const data = await george('/api/messages', {
+    query: { q: query, maxResults, account: ACCOUNT },
+  });
+  // George returns { messages: [...] } or a bare array depending on version.
+  return data.messages || data.results || (Array.isArray(data) ? data : []);
+}
+
+// Fetch a full message (headers + body + attachment metadata) by id.
+export async function getMessage(id) {
+  return george(`/api/messages/${encodeURIComponent(id)}`, { query: { account: ACCOUNT } });
+}
+
+export { ACCOUNT, BASE_URL };
diff --git a/lib/parse.js b/lib/parse.js
new file mode 100644
index 0000000..514efbc
--- /dev/null
+++ b/lib/parse.js
@@ -0,0 +1,182 @@
+// Thread parsing + gating logic for the discontinued-agent.
+//
+// A follow-up thread contains:
+//   - DW OUTBOUND message(s) (from info@designerwallcoverings.com) that list the
+//     requested SKUs under a "Manufacturer Number" section.
+//   - VENDOR REPLY message(s) that may state a product is discontinued.
+//
+// We extract the candidate mfr#(s) from the DW outbound, read the vendor reply
+// text, and classify each thread into: COMMIT (high-confidence explicit
+// discontinuation) / QUEUE (ambiguous/sensitive) / SKIP (nothing actionable).
+
+import crypto from 'node:crypto';
+
+const DW_FROM_RE = /info@designerwallcoverings\.com/i;
+
+// Strip HTML to readable-ish plain text.
+export function htmlToText(html = '') {
+  return String(html)
+    .replace(/<style[\s\S]*?<\/style>/gi, ' ')
+    .replace(/<script[\s\S]*?<\/script>/gi, ' ')
+    .replace(/<br\s*\/?>/gi, '\n')
+    .replace(/<\/(p|div|tr|li|h[1-6]|table)>/gi, '\n')
+    .replace(/<[^>]+>/g, ' ')
+    .replace(/&nbsp;/gi, ' ')
+    .replace(/&amp;/gi, '&')
+    .replace(/&lt;/gi, '<')
+    .replace(/&gt;/gi, '>')
+    .replace(/&#39;|&rsquo;|&lsquo;/gi, "'")
+    .replace(/&quot;|&ldquo;|&rdquo;/gi, '"')
+    .replace(/[ \t]+/g, ' ')
+    .replace(/\n{3,}/g, '\n\n')
+    .trim();
+}
+
+export function isDwOutbound(msg) {
+  return DW_FROM_RE.test(msg.from || '');
+}
+
+// Mfr# token pattern — vendor style numbers like CM104-2180, TCO08, 5008040,
+// W3612-11, T75461. Alphanumerics with optional dashes/slashes, length >=4,
+// containing at least one digit. Deliberately permissive; the FileMaker match
+// re-validates by finding the row.
+const MFR_TOKEN_RE = /\b([A-Z0-9]{1,}(?:[-/. ][A-Z0-9]{1,}){0,3})\b/g;
+
+// The DW outbound footer is a fixed block — its tokens must never be treated
+// as mfr#s. (Address line, city/state/zip, toll-free number.)
+const FOOTER_BLOCKLIST = [
+  /^15442$/, // street number
+  /^CA\s*9\d{4}$/i, // CA + zip
+  /^9\d{4}$/, // bare CA zip
+  /^1?[-.\s]?888[-.\s]?373[-.\s]?4564$/, // DW toll-free
+  /^373[-.\s]?4564$/,
+  /^102$/, // suite #
+];
+
+function looksLikeMfr(tok) {
+  const t = tok.trim();
+  if (t.length < 4 || t.length > 24) return false;
+  if (!/[0-9]/.test(t)) return false; // must contain a digit
+  if (!/[A-Z0-9]/i.test(t)) return false;
+  // reject pure prose numbers / dates / phone-ish / money / time-of-day
+  if (/^\d{1,2}[/.]\d{1,2}([/.]\d{2,4})?$/.test(t)) return false; // dates
+  if (/^\$?\d+[.,]\d{2}$/.test(t)) return false; // money
+  if (/\b(?:AM|PM)\b/i.test(t)) return false; // time fragments like "22 AM"
+  if (/^(10|days|over|2026|2025|2024|2023)$/i.test(t)) return false;
+  if (FOOTER_BLOCKLIST.some((re) => re.test(t))) return false;
+  return true;
+}
+
+// Grab the LAST mfr-looking token on a line — the DW outbound lists each item
+// as "<Pattern Name> <MFR#>" (e.g. "Shooting Star CM104-2180").
+function lastMfrTokenOnLine(line) {
+  let last = null;
+  let mm;
+  const re = new RegExp(MFR_TOKEN_RE.source, 'g');
+  while ((mm = re.exec(line)) !== null) {
+    const tok = mm[1].trim();
+    if (looksLikeMfr(tok)) last = tok;
+  }
+  return last;
+}
+
+// Extract mfr#(s) from a DW outbound message.
+//
+// The outbound lists them in a block delimited like:
+//     Manufacturer Number
+//     _________________
+//     Shooting Star CM104-2180
+//     Shooting Star CM104-2170
+//     _________________
+//     Ship to the address below.   <- footer (address/phone) starts here
+//
+// So we scope extraction to the lines BETWEEN the "Manufacturer Number" heading
+// and the NEXT underscore/asterisk divider, taking the trailing mfr# per line.
+// This deliberately excludes the address/phone footer that follows.
+export function extractMfrNumbers(outboundText) {
+  const text = outboundText || '';
+  const found = new Set();
+
+  const lines = text.split(/\n/);
+  const headIdx = lines.findIndex((l) => /manufacturer\s*(?:number|#|no\.?)/i.test(l));
+
+  if (headIdx >= 0) {
+    for (let i = headIdx + 1; i < lines.length; i++) {
+      const raw = lines[i];
+      const line = raw.replace(/^[>\s]+/, '').trim();
+      if (!line) continue;
+      // Divider line (all underscores / dashes) — skip until we hit content.
+      if (/^[_\-–—\s]{3,}$/.test(line)) {
+        // A divider AFTER we've already collected items ends the block.
+        if (found.size > 0) break;
+        continue;
+      }
+      // A "Manufacturer Number" label line on its own — skip.
+      if (/^manufacturer\s*(?:number|#|no\.?)\s*:?\s*$/i.test(line)) continue;
+      // Explicit "Manufacturer Number: XXX" form.
+      const labeled = line.match(/manufacturer\s*(?:number|#|no\.?)\s*[:\-]?\s*(.+)$/i);
+      const target = labeled ? labeled[1] : line;
+      const tok = lastMfrTokenOnLine(target);
+      if (tok) {
+        found.add(tok.toUpperCase());
+      } else if (found.size > 0) {
+        // Once we've started collecting, a non-mfr content line (e.g. "Ship to
+        // the address below.") marks the end of the item block.
+        break;
+      }
+    }
+  }
+
+  // NO whole-body fallback: if the structured "Manufacturer Number" block
+  // yielded nothing, we return empty so the thread is QUEUED as "no mfr#"
+  // rather than risking a false match from prose/footer tokens.
+  return [...found];
+}
+
+// --- Gating classification ---
+// HIGH-CONFIDENCE explicit discontinuation phrases.
+const DISCO_PHRASES = [
+  /\bdiscontinued\b/i,
+  /\bno longer available\b/i,
+  /\bno longer offer(?:ed|ing)?\b/i,
+  /\bno longer carr(?:y|ied|ies)\b/i,
+  /\bhas been discontinued\b/i,
+  /\bcan\s?not (?:be )?order(?:ed)?[^.]{0,40}discontinued/i,
+  /\bdiscontinued[^.]{0,40}can\s?not (?:be )?order/i,
+];
+
+// QUEUE-worthy (ambiguous / sensitive) phrases — do NOT auto-discontinue.
+const QUEUE_PHRASES = [
+  { re: /\bno sample(?:s)? available\b/i, why: 'no sample available (not a discontinuation)' },
+  { re: /\bout of stock\b/i, why: 'out of stock (not a discontinuation)' },
+  { re: /\bback\s?order(?:ed)?\b/i, why: 'backordered / no restock date' },
+  { re: /\bno restock\b/i, why: 'no restock date' },
+  { re: /\bended (?:our|the) (?:retailer|vendor|reseller|distributor) relationship\b/i, why: 'vendor ended retailer/vendor relationship (relationship-ended)' },
+  { re: /\bwithout brand attribution\b/i, why: 'brand-attribution complaint' },
+  { re: /\bbrand attribution\b/i, why: 'brand-attribution concern' },
+  { re: /\bcease and desist\b/i, why: 'legal / cease-and-desist' },
+  { re: /\bnot (?:an )?authorized (?:dealer|retailer)\b/i, why: 'authorization dispute' },
+  { re: /\bremove (?:our|the) (?:products|styles|patterns|brand)\b/i, why: 'vendor asked to remove listings' },
+];
+
+// Classify a vendor reply's text.
+export function classifyReply(replyText) {
+  const text = replyText || '';
+
+  // Sensitive/ambiguous flags always take precedence (fail-safe to QUEUE).
+  const queueHits = QUEUE_PHRASES.filter((q) => q.re.test(text)).map((q) => q.why);
+  const discoHit = DISCO_PHRASES.find((re) => re.test(text));
+
+  if (queueHits.length > 0) {
+    return { verdict: 'QUEUE', reasons: queueHits, discontinuedPhrase: discoHit ? discoHit.source : null };
+  }
+  if (discoHit) {
+    return { verdict: 'COMMIT', reasons: ['explicit discontinuation'], discontinuedPhrase: discoHit.source };
+  }
+  return { verdict: 'SKIP', reasons: ['no discontinuation or sensitive signal'], discontinuedPhrase: null };
+}
+
+// Stable hash of the vendor message content (for the processed-state key).
+export function hashMsg(str) {
+  return crypto.createHash('sha256').update(String(str)).digest('hex').slice(0, 16);
+}
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..857f18c
--- /dev/null
+++ b/package.json
@@ -0,0 +1,18 @@
+{
+  "name": "discontinued-agent",
+  "version": "1.0.0",
+  "description": "Watches info@ Gmail for vendor 'discontinued' replies to DW sample-follow-up emails; stamps the wallpaper record discontinued and closes the open sample order in FileMaker.",
+  "type": "module",
+  "private": true,
+  "bin": {
+    "discontinued-agent": "poller.js"
+  },
+  "scripts": {
+    "once": "node poller.js --once",
+    "start": "node poller.js",
+    "dry": "node poller.js --once"
+  },
+  "engines": {
+    "node": ">=20"
+  }
+}
diff --git a/poller.js b/poller.js
new file mode 100644
index 0000000..0927032
--- /dev/null
+++ b/poller.js
@@ -0,0 +1,339 @@
+#!/usr/bin/env node
+// discontinued-agent — poller daemon.
+//
+// Watches info@designerwallcoverings.com for vendor replies to DW's
+// "Priority Request - Samples over 10 days old" sample-follow-up emails.
+// When a vendor EXPLICITLY says a product is discontinued, it stamps the
+// wallpaper record discontinued in FileMaker and closes the open sample order
+// (so the >10-day follow-up system stops re-asking).
+//
+// Modes:
+//   node poller.js            -> daemon: loops every POLL_INTERVAL_SEC seconds
+//   node poller.js --once     -> run a single scan and exit
+//
+// SAFETY: unless DISCO_AUTOCOMMIT=1, this NEVER commits a FileMaker write. It
+// stages the dry-run diff, logs it, and (for QUEUE cases) parks a memo.
+//
+// Cost: George (Gmail) + FileMaker Cloud are local/self-hosted = $0 per call.
+
+import { readFileSync, writeFileSync, existsSync, appendFileSync, mkdirSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import { dirname, join } from 'node:path';
+
+import * as George from './lib/george.js';
+import * as FM from './lib/filemaker.js';
+import {
+  htmlToText,
+  isDwOutbound,
+  extractMfrNumbers,
+  classifyReply,
+  hashMsg,
+} from './lib/parse.js';
+
+const __dir = dirname(fileURLToPath(import.meta.url));
+const DATA_DIR = join(__dir, 'data');
+const PROCESSED_FILE = join(DATA_DIR, 'processed-threads.json');
+const LEDGER_FILE = join(DATA_DIR, 'ledger.jsonl');
+
+const AUTOCOMMIT = process.env.DISCO_AUTOCOMMIT === '1';
+const QUEUE_DIR = process.env.QUEUE_DIR || join(process.env.HOME || '', '.claude/yolo-queue/pending-approval');
+const GMAIL_SEARCH =
+  process.env.GMAIL_SEARCH ||
+  'subject:"Priority Request - Samples over 10 days old" newer_than:60d';
+const POLL_INTERVAL_SEC = Number(process.env.POLL_INTERVAL_SEC || 10);
+const MAX_RESULTS = Number(process.env.MAX_RESULTS || 100);
+
+function log(...a) {
+  console.log(new Date().toISOString(), '[disco]', ...a);
+}
+
+// ---- state ----
+function loadProcessed() {
+  try {
+    return JSON.parse(readFileSync(PROCESSED_FILE, 'utf8'));
+  } catch {
+    return {};
+  }
+}
+function saveProcessed(state) {
+  mkdirSync(DATA_DIR, { recursive: true });
+  writeFileSync(PROCESSED_FILE, JSON.stringify(state, null, 2));
+}
+function appendLedger(entry) {
+  mkdirSync(DATA_DIR, { recursive: true });
+  appendFileSync(LEDGER_FILE, JSON.stringify({ ts: new Date().toISOString(), ...entry }) + '\n');
+}
+
+function queueMemo(name, body) {
+  mkdirSync(QUEUE_DIR, { recursive: true });
+  const stamp = new Date().toISOString().replace(/[:.]/g, '-');
+  const path = join(QUEUE_DIR, `discontinued-agent-${name}-${stamp}.md`);
+  writeFileSync(path, body);
+  return path;
+}
+
+// ---- thread processing ----
+
+// Build the {threadId -> [messages]} grouping from a flat list, ordered oldest->newest.
+function groupByThread(messages) {
+  const g = new Map();
+  for (const m of messages) {
+    if (!g.has(m.threadId)) g.set(m.threadId, []);
+    g.get(m.threadId).push(m);
+  }
+  for (const [, arr] of g) {
+    arr.sort((a, b) => Number(a.internalDate || 0) - Number(b.internalDate || 0));
+  }
+  return g;
+}
+
+// Given full messages for a thread, split into DW outbound + vendor replies.
+function splitThread(fullMsgs) {
+  const outbound = [];
+  const vendor = [];
+  for (const m of fullMsgs) {
+    if (isDwOutbound(m)) outbound.push(m);
+    else vendor.push(m);
+  }
+  return { outbound, vendor };
+}
+
+// Decide the disposition of one thread. Returns a plan object (no side effects).
+function planThread(threadId, fullMsgs) {
+  const { outbound, vendor } = splitThread(fullMsgs);
+  const outboundText = outbound.map((m) => htmlToText(m.body || '')).join('\n\n');
+  const mfrNumbers = extractMfrNumbers(outboundText);
+
+  // Newest vendor reply drives the verdict.
+  const latestVendor = vendor[vendor.length - 1] || null;
+  const vendorText = latestVendor ? htmlToText(latestVendor.body || '') : '';
+  const vendorFrom = latestVendor ? latestVendor.from : null;
+  const cls = classifyReply(vendorText);
+
+  return {
+    threadId,
+    vendorFrom,
+    vendorMsgId: latestVendor ? latestVendor.id : null,
+    vendorHash: hashMsg(vendorText),
+    mfrNumbers,
+    verdict: cls.verdict,
+    reasons: cls.reasons,
+    outboundCount: outbound.length,
+    vendorCount: vendor.length,
+    vendorText,
+  };
+}
+
+// Execute the FileMaker writes for a COMMIT plan (dry-run unless AUTOCOMMIT).
+async function applyCommit(plan) {
+  const today = FM.fmToday();
+  const F = FM.FIELDS;
+  const perMfr = [];
+
+  for (const mfr of plan.mfrNumbers) {
+    let rows;
+    try {
+      rows = await FM.findByMfrPattern(mfr);
+    } catch (e) {
+      perMfr.push({ mfr, error: `find failed: ${e.message}` });
+      continue;
+    }
+    const discoWrites = [];
+    const closeWrites = [];
+
+    for (const rec of rows) {
+      const { recordId, fields } = FM.recRow(rec);
+      const blankDisco = !String(fields[F.dateDiscontinued] || '').trim();
+      const isOpenOrder =
+        String(fields[F.account] || fields[F.company] || '').trim() &&
+        String(fields[F.requestPrinted] || '').trim() &&
+        !String(fields[F.dateSampleSent] || '').trim();
+
+      // WRITE 1 — discontinue (only if currently blank)
+      if (blankDisco) {
+        const r = await FM.updateRecord(recordId, { [F.dateDiscontinued]: today }, { dryRun: !AUTOCOMMIT });
+        discoWrites.push({ recordId, field: F.dateDiscontinued, ...r });
+      }
+      // WRITE 2 — close open order (blank Date WP Sample Sent on an open request)
+      if (isOpenOrder) {
+        const r = await FM.updateRecord(recordId, { [F.dateSampleSent]: today }, { dryRun: !AUTOCOMMIT });
+        closeWrites.push({ recordId, field: F.dateSampleSent, ...r });
+      }
+    }
+    perMfr.push({ mfr, rowsMatched: rows.length, discoWrites, closeWrites });
+  }
+  return { today, perMfr };
+}
+
+async function processThread(threadId, headers, state) {
+  // Fetch full bodies for each message in the thread.
+  const full = [];
+  for (const h of headers) {
+    try {
+      full.push(await George.getMessage(h.id));
+    } catch (e) {
+      log('getMessage failed', h.id, e.message);
+    }
+  }
+  if (full.length === 0) return;
+
+  const plan = planThread(threadId, full);
+
+  // processed-state key: threadId + hash of the vendor message.
+  const key = `${threadId}:${plan.vendorHash}`;
+  if (state[key]) return; // already handled this exact vendor message
+
+  if (plan.verdict === 'SKIP') {
+    // Mark processed so we don't re-read it every cycle, but log nothing heavy.
+    state[key] = { at: new Date().toISOString(), verdict: 'SKIP' };
+    return;
+  }
+
+  if (plan.verdict === 'QUEUE') {
+    const memoBody = [
+      `# discontinued-agent — QUEUED for Steve's review`,
+      ``,
+      `- **When:** ${new Date().toISOString()}`,
+      `- **Thread:** ${threadId}`,
+      `- **Vendor:** ${plan.vendorFrom || 'unknown'}`,
+      `- **Why queued (not auto-discontinued):** ${plan.reasons.join('; ')}`,
+      `- **Candidate mfr#(s) in thread:** ${plan.mfrNumbers.join(', ') || '(none extracted)'}`,
+      ``,
+      `## Vendor reply (excerpt)`,
+      '```',
+      plan.vendorText.slice(0, 1500),
+      '```',
+      ``,
+      `No FileMaker writes were made. Review and, if a discontinuation is warranted,`,
+      `stamp \`Date Discontinued\` + close the open order manually or re-run with a`,
+      `confirmed mfr# via the skill.`,
+    ].join('\n');
+    const memoPath = queueMemo(`queue-${threadId}`, memoBody);
+    appendLedger({
+      threadId,
+      vendor: plan.vendorFrom,
+      verdict: 'QUEUE',
+      reasons: plan.reasons,
+      mfrNumbers: plan.mfrNumbers,
+      memoPath,
+      fieldsWritten: [],
+    });
+    state[key] = { at: new Date().toISOString(), verdict: 'QUEUE', memoPath };
+    log('QUEUE', threadId, plan.vendorFrom, '->', plan.reasons.join('; '));
+    return;
+  }
+
+  // COMMIT path — but only if we have at least one clean mfr# to act on.
+  if (plan.mfrNumbers.length === 0) {
+    const memoPath = queueMemo(
+      `nomfr-${threadId}`,
+      `# discontinued-agent — discontinuation stated but no mfr# mapped\n\n` +
+        `- Thread: ${threadId}\n- Vendor: ${plan.vendorFrom}\n` +
+        `- The vendor reply says discontinued but no manufacturer number could be\n` +
+        `  cleanly extracted from the DW outbound. Queued for Steve.\n\n` +
+        '## Vendor reply\n```\n' + plan.vendorText.slice(0, 1500) + '\n```\n'
+    );
+    appendLedger({ threadId, vendor: plan.vendorFrom, verdict: 'QUEUE_NO_MFR', reasons: plan.reasons, mfrNumbers: [], memoPath, fieldsWritten: [] });
+    state[key] = { at: new Date().toISOString(), verdict: 'QUEUE_NO_MFR', memoPath };
+    log('QUEUE(no mfr#)', threadId, plan.vendorFrom);
+    return;
+  }
+
+  const result = await applyCommit(plan);
+  const fieldsWritten = [];
+  const recordIds = [];
+  for (const pm of result.perMfr) {
+    for (const w of [...(pm.discoWrites || []), ...(pm.closeWrites || [])]) {
+      recordIds.push(w.recordId);
+      fieldsWritten.push({ recordId: w.recordId, field: w.field, committed: !!w.committed, changes: w.changes });
+    }
+  }
+
+  appendLedger({
+    threadId,
+    vendor: plan.vendorFrom,
+    verdict: AUTOCOMMIT ? 'COMMIT' : 'STAGED(dry-run)',
+    reasons: plan.reasons,
+    mfrNumbers: plan.mfrNumbers,
+    today: result.today,
+    recordIds,
+    fieldsWritten,
+    autocommit: AUTOCOMMIT,
+  });
+
+  // Only mark processed when we actually committed. In dry-run mode we leave it
+  // un-processed so a later run with DISCO_AUTOCOMMIT=1 can complete the write.
+  if (AUTOCOMMIT) {
+    state[key] = { at: new Date().toISOString(), verdict: 'COMMIT', recordIds };
+  }
+  log(
+    AUTOCOMMIT ? 'COMMIT' : 'STAGED(dry-run)',
+    threadId,
+    plan.vendorFrom,
+    'mfr#', plan.mfrNumbers.join(','),
+    'rows', recordIds.length
+  );
+}
+
+async function scanOnce() {
+  const state = loadProcessed();
+  let headers;
+  try {
+    headers = await George.listMessages(GMAIL_SEARCH, { maxResults: MAX_RESULTS });
+  } catch (e) {
+    log('George list failed:', e.message);
+    return;
+  }
+  const threads = groupByThread(headers);
+  log(`scanned ${headers.length} msgs across ${threads.size} threads`);
+
+  for (const [threadId, msgs] of threads) {
+    try {
+      await processThread(threadId, msgs, state);
+    } catch (e) {
+      log('thread error', threadId, e.message);
+    }
+  }
+  saveProcessed(state);
+}
+
+async function main() {
+  const once = process.argv.includes('--once');
+  log(`starting. autocommit=${AUTOCOMMIT ? 'ON' : 'OFF (dry-run/stage only)'} search="${GMAIL_SEARCH}"`);
+
+  // Health preflight (non-fatal).
+  try {
+    const h = await George.health();
+    log('George health:', h.status || 'ok');
+  } catch (e) {
+    log('WARN George health check failed:', e.message);
+  }
+  try {
+    const p = await FM.ping();
+    log('FileMaker ping:', p.ok ? `ok (${p.database} @ ${p.host})` : 'unexpected');
+  } catch (e) {
+    log('WARN FileMaker ping failed:', e.message);
+  }
+
+  if (once) {
+    await scanOnce();
+    log('single scan complete.');
+    return;
+  }
+
+  // Daemon loop — every POLL_INTERVAL_SEC seconds, never overlapping.
+  log(`daemon loop every ${POLL_INTERVAL_SEC}s`);
+  for (;;) {
+    try {
+      await scanOnce();
+    } catch (e) {
+      log('scan error:', e.message);
+    }
+    await new Promise((r) => setTimeout(r, POLL_INTERVAL_SEC * 1000));
+  }
+}
+
+main().catch((e) => {
+  log('FATAL', e.stack || e.message);
+  process.exit(1);
+});

(oldest)  ·  back to Discontinued Agent  ·  Add disco/not-going-to-make-again triggers + info@ run-compl 85a3914 →