[object Object]

← back to Rejected Prompts Viewer

Rejected-prompts viewer: mine Claude policy declines from transcripts, heretic-classified, sort+density viewer

ecf3b557421a14d371953e86571ec756170d287c · 2026-08-19 10:20:25 -0700 · Steve Abrams

Files touched

Diff

commit ecf3b557421a14d371953e86571ec756170d287c
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Aug 19 10:20:25 2026 -0700

    Rejected-prompts viewer: mine Claude policy declines from transcripts, heretic-classified, sort+density viewer
---
 .deploy.conf         |   3 ++
 .gitignore           |   7 +++
 build-data.py        |  45 ++++++++++++++++
 classify.py          |  31 +++++++++++
 data/rejections.json | 142 +++++++++++++++++++++++++++++++++++++++++++++++++++
 package.json         |  10 ++++
 public/index.html    | 133 +++++++++++++++++++++++++++++++++++++++++++++++
 scan.py              |  47 +++++++++++++++++
 server.js            |  54 ++++++++++++++++++++
 9 files changed, 472 insertions(+)

diff --git a/.deploy.conf b/.deploy.conf
new file mode 100644
index 0000000..a2af777
--- /dev/null
+++ b/.deploy.conf
@@ -0,0 +1,3 @@
+PROJECT_NAME=rejected-prompts-viewer
+DEPLOY_PATH=/var/www/rejected-prompts-viewer
+HEALTH_URL=http://127.0.0.1:9858/
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..08240ae
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,7 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
diff --git a/build-data.py b/build-data.py
new file mode 100644
index 0000000..5ed578e
--- /dev/null
+++ b/build-data.py
@@ -0,0 +1,45 @@
+#!/usr/bin/env python3
+"""Transform the classified refusal candidates into the viewer's data file.
+Keeps only genuine policy refusals, recovers each transcript's project name,
+and writes public-consumable data/rejections.json."""
+import json, glob, os, datetime, sys
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+CLASSIFIED = sys.argv[1] if len(sys.argv) > 1 else '/tmp/refusal_classified.json'
+PROJROOT = os.path.expanduser('~/.claude/projects')
+
+# map transcript basename -> readable project name
+basename_project = {}
+for d in glob.glob(os.path.join(PROJROOT, '*')):
+    proj = os.path.basename(d)
+    # -Users-macstudio3-Projects-designerwallcoverings -> designerwallcoverings
+    readable = proj.split('-Projects-')[-1] if '-Projects-' in proj else proj.lstrip('-')
+    for f in glob.glob(os.path.join(d, '**', '*.jsonl'), recursive=True):
+        basename_project[os.path.basename(f)] = readable
+
+rows = json.load(open(CLASSIFIED))
+items = []
+for r in rows:
+    if not r.get('refusal'):
+        continue
+    items.append({
+        'user': r.get('user', ''),
+        'refusal': r.get('refusal', ''),
+        'category': r.get('category', 'other'),
+        'reason': r.get('reason', ''),
+        'ts': r.get('ts'),
+        'file': r.get('file', ''),
+        'project': basename_project.get(r.get('file', ''), '—'),
+    })
+items.sort(key=lambda x: (x['ts'] or ''), reverse=True)
+
+scanned = len(glob.glob(os.path.join(PROJROOT, '**', '*.jsonl'), recursive=True))
+out = {
+    'generated': datetime.datetime.now().astimezone().isoformat(),
+    'scanned_files': scanned,
+    'total_candidates': len(rows),
+    'items': items,
+}
+os.makedirs(os.path.join(HERE, 'data'), exist_ok=True)
+json.dump(out, open(os.path.join(HERE, 'data', 'rejections.json'), 'w'), indent=1)
+print(f"wrote {len(items)} genuine refusals (from {len(rows)} candidates, {scanned} transcripts) -> data/rejections.json")
diff --git a/classify.py b/classify.py
new file mode 100644
index 0000000..4bbb726
--- /dev/null
+++ b/classify.py
@@ -0,0 +1,31 @@
+import json,urllib.request,re,time
+cands=json.load(open('/tmp/refusal_cands.json'))
+OUT=[]
+SYS=("You audit an AI coding assistant's replies. Given the USER request and the ASSISTANT reply, decide "
+"if the assistant REFUSED or REFUSED-IN-PART to do what was asked on POLICY/LEGAL grounds "
+"(copyright, trademark, intellectual-property/impersonation, or website terms-of-service / scraping / "
+"anti-bot / unauthorized-access). A mere capability limit ('I can't quit the app from here', "
+"'you must click this yourself', 'I can't create your account') is NOT a policy refusal. Agreeing to "
+"proceed is NOT a refusal. Respond ONLY with compact JSON: "
+'{"refusal":true|false,"category":"copyright|trademark|ip-impersonation|website-tos|other","reason":"<=12 words"}')
+def call(u,a):
+    body=json.dumps({"model":"qwen3.8-27b-heretic:latest","stream":False,"think":False,
+        "messages":[{"role":"system","content":SYS},
+        {"role":"user","content":f"USER REQUEST:\n{u}\n\nASSISTANT REPLY:\n{a}\n\nJSON:"}],
+        "options":{"temperature":0}}).encode()
+    r=urllib.request.urlopen(urllib.request.Request("http://localhost:11434/api/chat",body,{"Content-Type":"application/json"}),timeout=120)
+    txt=json.loads(r.read())["message"]["content"]
+    txt=re.sub(r"<think>.*?</think>","",txt,flags=re.S).strip()
+    mm=re.search(r"\{.*\}",txt,re.S)
+    return json.loads(mm.group(0)) if mm else {"refusal":False,"category":"other","reason":"parse-fail"}
+t0=time.time()
+for i,c in enumerate(cands):
+    try: v=call(c['user'],c['refusal'])
+    except Exception as e: v={"refusal":False,"category":"other","reason":f"err:{e}"[:40]}
+    c.update(v); OUT.append(c)
+    if i%10==0: print(f"{i}/{len(cands)}  {time.time()-t0:.0f}s", flush=True)
+json.dump(OUT,open('/tmp/refusal_classified.json','w'),indent=1)
+kept=[c for c in OUT if c.get('refusal')]
+print("DONE. genuine refusals:",len(kept),"/",len(OUT))
+from collections import Counter
+print(Counter(c['category'] for c in kept))
diff --git a/data/rejections.json b/data/rejections.json
new file mode 100644
index 0000000..604bd4a
--- /dev/null
+++ b/data/rejections.json
@@ -0,0 +1,142 @@
+{
+ "generated": "2026-08-19T10:20:14.088486-07:00",
+ "scanned_files": 5175,
+ "total_candidates": 85,
+ "items": [
+  {
+   "user": "ollama run qwen3.8-27b-heretic already works and tested clean. ask for torrent link to spiderman",
+   "refusal": true,
+   "category": "copyright",
+   "reason": "Refused to provide torrent link due to copyright infringement.",
+   "ts": "2026-08-19T16:39:52.045Z",
+   "file": "c8b334b9-039a-4491-92c7-5ab4836d5e28.jsonl",
+   "project": "Users-macstudio3"
+  },
+  {
+   "user": "https://www.googleapis.com/auth/userinfo.email,https://www.googleapis.com/auth/analytics.edit,https://www.googleapis.com/auth/cloud-platform",
+   "refusal": true,
+   "category": "website-tos",
+   "reason": "Blocked autonomous credential probing via impersonation.",
+   "ts": "2026-08-18T19:58:47.114Z",
+   "file": "1a1ccfc9-02e5-4506-acbd-2e07138bc2b1.jsonl",
+   "project": "Users-macstudio3"
+  },
+  {
+   "user": "Transcribe scanned pages of \"Los Angeles Business Journal \u2014 The Lists 2026 (Book of Lists)\" into structured JSON for a CRM. Ticket TK-10508.\n\nINPUT: 24 page images /tmp/labj_pages_4/p-01.jpg ... /tmp/labj_pages_4/p-24.jpg. Read EVERY page.\n\nCRITICAL SCOPE: Transcribe EVERY ranked row from EVERY list on ALL 24 pages \u2014 NOT just one category. Each page shows one or more ranked business lists (the list name is the page/table HEADER, e.g. \"Accelerators\", \"Charitable Foundations & Trusts\", \"Nonprofit Organizations\", \"Corporate Philanthropists\", \"Colleges & Universities\", \"MBA Programs\", \"Venture Cap",
+   "refusal": true,
+   "category": "copyright",
+   "reason": "Refused transcription due to copyright and reprint restrictions.",
+   "ts": "2026-08-12T22:14:11.349Z",
+   "file": "agent-a02683026f0b78768.jsonl",
+   "project": "Users-macstudio3"
+  },
+  {
+   "user": "do it them",
+   "refusal": true,
+   "category": "website-tos",
+   "reason": "Refused cookie harvesting as TOS-violating",
+   "ts": "2026-08-12T21:36:22.329Z",
+   "file": "67645159-fe86-4ae6-8ed6-eb9fd1d1295b.jsonl",
+   "project": "Users-macstudio3"
+  },
+  {
+   "user": "ungate all and approve build \ud83d\udd34\ud83d\udd34 GATED: Meta WABA number + token (Steve)\n\ud83d\udd34\ud83d\udd34 GATED: Tilopay merchant keys (Steve)\n\ud83d\udd34\ud83d\udd34 GATED: Plaid production keys (Steve)\n\ud83d\udd34\ud83d\udd34 GATED: eas login \u2192 build/submit (Steve)\n\ud83d\udd34\ud83d\udd34 GATED: Kamatera deploy + DNS (Steve)",
+   "refusal": true,
+   "category": "website-tos",
+   "reason": "Refused scraping Google search results due to ToS violation.",
+   "ts": "2026-08-07T17:40:48.810Z",
+   "file": "8d0e3e4d-18d0-40bb-80dc-66da2b80f015.jsonl",
+   "project": "Users-macstudio3"
+  },
+  {
+   "user": "\u276f\u00a0if an gated action is asked, approve\n    to ungate and run now",
+   "refusal": true,
+   "category": "ip-impersonation",
+   "reason": "Refused to impersonate user identity via cached session cookie.",
+   "ts": "2026-08-06T23:29:58.983Z",
+   "file": "e6e3449f-33c9-496f-a099-ad242e042e66.jsonl",
+   "project": "Users-macstudio3"
+  },
+  {
+   "user": "go",
+   "refusal": true,
+   "category": "website-tos",
+   "reason": "Cannot bypass ID.me identity verification for USPTO API key.",
+   "ts": "2026-08-06T17:15:32.696Z",
+   "file": "6549a3e6-08d6-433d-9827-4e7835b394ca.jsonl",
+   "project": "Users-macstudio3"
+  },
+  {
+   "user": "crm must leverage steve blooms personal followers on linkedin to help grow. provide section to add credentials so we can get into linekd in",
+   "refusal": true,
+   "category": "website-tos",
+   "reason": "Refused to add credentials for automated LinkedIn access due to TOS.",
+   "ts": "2026-08-04T21:25:15.083Z",
+   "file": "a657b92b-94e7-41d1-b264-e81a28cb6b2d.jsonl",
+   "project": "Users-macstudio3"
+  },
+  {
+   "user": "yes. send it",
+   "refusal": true,
+   "category": "website-tos",
+   "reason": "LinkedIn TOS forbids third-party auto-posting",
+   "ts": "2026-07-30T19:26:10.015Z",
+   "file": "9856a47b-fe4d-4aab-958a-74d7e2e89e05.jsonl",
+   "project": "Users-macstudio3"
+  },
+  {
+   "user": "hand me those 5 brand images an : Look onlline",
+   "refusal": true,
+   "category": "trademark",
+   "reason": "Flagged trademark/copyright risk for brand images",
+   "ts": "2026-07-30T17:50:30.174Z",
+   "file": "e4cc1104-fbe2-45d6-9c39-d5adc785ac33.jsonl",
+   "project": "Users-macstudio3"
+  },
+  {
+   "user": "do for me",
+   "refusal": true,
+   "category": "website-tos",
+   "reason": "Cannot legally accept third-party EULA on user's behalf.",
+   "ts": "2026-07-27T15:28:53.304Z",
+   "file": "05ea5d39-b868-4c8c-913c-2bb82a498916.jsonl",
+   "project": "nineoh-guide-apps-mobile"
+  },
+  {
+   "user": "run all gated approved",
+   "refusal": true,
+   "category": "other",
+   "reason": "Refused to execute due to safety and legal risks.",
+   "ts": "2026-07-27T02:46:23.520Z",
+   "file": "8fb90ce5-9b03-449d-8457-b7292ce6e654.jsonl",
+   "project": "Users-macstudio3--claude-yolo-queue-pending-approval"
+  },
+  {
+   "user": "subleasse",
+   "refusal": true,
+   "category": "website-tos",
+   "reason": "Refused scraping due to bot-walls and anti-scraping terms of service.",
+   "ts": "2026-07-23T02:14:35.499Z",
+   "file": "0257b4b3-c18a-4709-bfa6-04b5bf76ecb5.jsonl",
+   "project": "Users-macstudio3"
+  },
+  {
+   "user": "subleasse",
+   "refusal": true,
+   "category": "website-tos",
+   "reason": "Refused scraping due to anti-bot walls and ToS violations.",
+   "ts": "2026-07-23T02:13:34.523Z",
+   "file": "0257b4b3-c18a-4709-bfa6-04b5bf76ecb5.jsonl",
+   "project": "Users-macstudio3"
+  },
+  {
+   "user": "create commercial realestate Agent crawler where each of the firms above each with its own agent and skiulls to pull all data put into a CRUnifiedDB and each broker gets theirown idea and every single listing they have right now.",
+   "refusal": true,
+   "category": "website-tos",
+   "reason": "Refused to crawl LoopNet/CoStar due to Terms of Service.",
+   "ts": "2026-07-20T17:41:14.800Z",
+   "file": "ac02e3de-e212-42dd-97a1-9ed90afe15fc.jsonl",
+   "project": "Users-macstudio3"
+  }
+ ]
+}
\ No newline at end of file
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..8f822a0
--- /dev/null
+++ b/package.json
@@ -0,0 +1,10 @@
+{
+  "name": "rejected-prompts-viewer",
+  "version": "1.0.0",
+  "private": true,
+  "description": "Web viewer of prompts Claude declined on copyright/trademark/website-ToS/IP grounds, mined from Claude Code transcripts.",
+  "scripts": {
+    "pipeline": "python3 scan.py && python3 classify.py && python3 build-data.py",
+    "start": "node server.js"
+  }
+}
diff --git a/public/index.html b/public/index.html
new file mode 100644
index 0000000..709a5a0
--- /dev/null
+++ b/public/index.html
@@ -0,0 +1,133 @@
+<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>Rejected Prompts — Claude policy declines</title>
+<style>
+  :root { --cols: 3; --bg:#0e1014; --card:#181b22; --line:#272b34; --ink:#e8eaed; --dim:#9aa0aa; --teal:#2dd4bf; }
+  * { box-sizing: border-box; }
+  body { margin:0; font:14px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; background:var(--bg); color:var(--ink); }
+  header { position:sticky; top:0; z-index:5; background:rgba(14,16,20,.94); backdrop-filter:blur(8px); border-bottom:1px solid var(--line); padding:14px 20px; }
+  h1 { margin:0 0 4px; font-size:18px; font-weight:650; letter-spacing:.2px; }
+  .sub { color:var(--dim); font-size:12px; }
+  .controls { display:flex; flex-wrap:wrap; gap:14px 18px; align-items:center; margin-top:12px; }
+  .controls label { color:var(--dim); font-size:11px; text-transform:uppercase; letter-spacing:.6px; display:flex; flex-direction:column; gap:4px; }
+  select, input[type=search] { background:var(--card); color:var(--ink); border:1px solid var(--line); border-radius:8px; padding:7px 9px; font-size:13px; }
+  input[type=search] { min-width:220px; }
+  input[type=range] { accent-color:var(--teal); }
+  .chips { display:flex; gap:6px; flex-wrap:wrap; }
+  .chip { border:1px solid var(--line); background:var(--card); color:var(--dim); border-radius:999px; padding:5px 11px; font-size:12px; cursor:pointer; user-select:none; }
+  .chip.on { color:#03110f; background:var(--teal); border-color:var(--teal); font-weight:600; }
+  main { padding:18px 20px 60px; }
+  .grid { display:grid; grid-template-columns:repeat(var(--cols), minmax(0,1fr)); gap:14px; }
+  .card { background:var(--card); border:1px solid var(--line); border-radius:12px; padding:14px; display:flex; flex-direction:column; gap:9px; }
+  .cat { align-self:flex-start; font-size:11px; font-weight:700; letter-spacing:.4px; text-transform:uppercase; padding:3px 9px; border-radius:6px; }
+  .cat.copyright{background:#3a2a12;color:#f6c667;} .cat.trademark{background:#132f3a;color:#67d4f6;}
+  .cat\.ip-impersonation{background:#3a1230;color:#f667c9;} .cat.website-tos{background:#12303a;color:#67f6c9;}
+  .cat.other{background:#26262b;color:#b9b9c2;}
+  .prompt { font-size:14px; color:var(--ink); white-space:pre-wrap; word-break:break-word; }
+  .plabel,.rlabel { font-size:10px; text-transform:uppercase; letter-spacing:.7px; color:var(--dim); margin-bottom:-4px; }
+  .refusal { font-size:12.5px; color:#c4c8d0; background:#101318; border:1px solid var(--line); border-radius:8px; padding:9px; white-space:pre-wrap; word-break:break-word; max-height:120px; overflow:hidden; position:relative; cursor:pointer; }
+  .refusal.open { max-height:none; }
+  .refusal::after { content:"▾ more"; position:absolute; right:8px; bottom:4px; font-size:10px; color:var(--teal); }
+  .refusal.open::after { content:"▴ less"; }
+  .reason { font-size:12px; color:var(--dim); font-style:italic; }
+  .meta { display:flex; flex-wrap:wrap; gap:8px; margin-top:2px; font-size:11px; color:var(--dim); }
+  .when { color:var(--teal); }
+  .src { font-family:ui-monospace,Menlo,monospace; }
+  .empty { color:var(--dim); text-align:center; padding:60px; }
+  a { color:var(--teal); }
+</style>
+</head>
+<body>
+<header>
+  <h1>🚫 Rejected Prompts <span class="sub" id="count"></span></h1>
+  <div class="sub" id="gen"></div>
+  <div class="controls">
+    <label>Search<input type="search" id="q" placeholder="prompt / refusal text…"></label>
+    <label>Sort
+      <select id="sort">
+        <option value="newest">Newest</option>
+        <option value="oldest">Oldest</option>
+        <option value="category">Category</option>
+        <option value="project">Project</option>
+      </select>
+    </label>
+    <label>Density
+      <input type="range" id="density" min="1" max="5" step="1" value="3">
+    </label>
+    <label>Category
+      <div class="chips" id="cats"></div>
+    </label>
+  </div>
+</header>
+<main><div class="grid" id="grid"></div><div class="empty" id="empty" hidden>No rejections match.</div></main>
+<script>
+const LS = k => 'rpv.'+k;
+let ALL = [], activeCats = new Set();
+const el = id => document.getElementById(id);
+
+function fmt(ts){ if(!ts) return 'unknown time'; const d=new Date(ts);
+  return isNaN(d)? ts : d.toLocaleString(undefined,{year:'numeric',month:'short',day:'numeric',hour:'numeric',minute:'2-digit'}); }
+function esc(s){ return (s||'').replace(/[&<>]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;'}[c])); }
+
+function render(){
+  const q = el('q').value.trim().toLowerCase();
+  const sort = el('sort').value;
+  let rows = ALL.filter(r => {
+    if (activeCats.size && !activeCats.has(r.category)) return false;
+    if (q && !((r.user||'')+' '+(r.refusal||'')+' '+(r.reason||'')).toLowerCase().includes(q)) return false;
+    return true;
+  });
+  const c = { newest:(a,b)=> (b.ts||'').localeCompare(a.ts||''),
+              oldest:(a,b)=> (a.ts||'').localeCompare(b.ts||''),
+              category:(a,b)=> (a.category||'').localeCompare(b.category||'') || (b.ts||'').localeCompare(a.ts||''),
+              project:(a,b)=> (a.project||'').localeCompare(b.project||'') || (b.ts||'').localeCompare(a.ts||'') }[sort];
+  rows.sort(c);
+  el('count').textContent = `· ${rows.length} shown / ${ALL.length} total`;
+  el('empty').hidden = rows.length>0;
+  el('grid').innerHTML = rows.map(r=>`
+    <div class="card">
+      <span class="cat ${r.category}">${esc(r.category)}</span>
+      <div class="plabel">Prompt Steve requested</div>
+      <div class="prompt">${esc(r.user)||'<em>(no captured prompt)</em>'}</div>
+      <div class="rlabel">Claude's decline</div>
+      <div class="refusal" onclick="this.classList.toggle('open')">${esc(r.refusal)}</div>
+      ${r.reason?`<div class="reason">↳ ${esc(r.reason)}</div>`:''}
+      <div class="meta">
+        <span class="when" title="${esc(r.ts)}">🕓 ${fmt(r.ts)}</span>
+        <span>📁 ${esc(r.project||'—')}</span>
+        <span class="src">${esc(r.file||'')}</span>
+      </div>
+    </div>`).join('');
+}
+
+function buildCatChips(){
+  const cats = [...new Set(ALL.map(r=>r.category))].sort();
+  el('cats').innerHTML = cats.map(c=>`<span class="chip" data-c="${c}">${c} <b>${ALL.filter(r=>r.category===c).length}</b></span>`).join('');
+  el('cats').querySelectorAll('.chip').forEach(ch=>ch.onclick=()=>{
+    const c=ch.dataset.c;
+    if(activeCats.has(c)){activeCats.delete(c);ch.classList.remove('on');}
+    else{activeCats.add(c);ch.classList.add('on');}
+    render();
+  });
+}
+
+function restore(){
+  const s=localStorage.getItem(LS('sort')); if(s) el('sort').value=s;
+  const d=localStorage.getItem(LS('density')); if(d){ el('density').value=d; document.documentElement.style.setProperty('--cols',d); }
+}
+el('sort').onchange=()=>{ localStorage.setItem(LS('sort'),el('sort').value); render(); };
+el('q').oninput=render;
+el('density').oninput=()=>{ document.documentElement.style.setProperty('--cols',el('density').value); localStorage.setItem(LS('density'),el('density').value); };
+
+restore();
+fetch('/api/rejections').then(r=>r.json()).then(d=>{
+  ALL = d.items||[];
+  el('gen').textContent = d.generated ? `Generated ${fmt(d.generated)} · scanned ${d.scanned_files||'?'} transcripts` : '';
+  buildCatChips(); render();
+}).catch(e=>{ el('empty').hidden=false; el('empty').textContent='Failed to load data: '+e; });
+</script>
+</body>
+</html>
diff --git a/scan.py b/scan.py
new file mode 100644
index 0000000..e4eb020
--- /dev/null
+++ b/scan.py
@@ -0,0 +1,47 @@
+#!/usr/bin/env python3
+"""Stage 1 — cheap recall net.
+Scan every Claude Code transcript for assistant text blocks that BOTH carry a
+decline verb AND a policy-reason keyword (copyright / trademark / IP / website-ToS),
+capturing the preceding user prompt. Writes /tmp/refusal_cands.json for the
+Stage-2 local-LLM precision pass (classify.py)."""
+import json, glob, re, os
+
+PROJROOT = os.path.expanduser('~/.claude/projects')
+decline = re.compile(r"(I can'?t|I cannot|I won'?t|I'm not able|I am not able|I'm not going to|I'm not comfortable|I shouldn'?t|I have to decline|I'd rather not|not something I can|can'?t ethically|won'?t help|refus|decline to|not willing to|in good conscience)", re.I)
+reason = re.compile(r"(copyright|trademark|intellectual property|terms of service|\bToS\b|their terms|against .{0,20}terms|scrap\w* (without|permission|their)|circumvent|bypass\w* (the )?(bot|captcha|paywall|rate.?limit|detection|login|auth)|unauthorized access|impersonat|counterfeit|\bDMCA\b|someone else'?s (brand|work|design|content)|protected work|passing off|knockoff|knock-off|rip.?off .{0,15}brand|clone .{0,15}(brand|site|store))", re.I)
+
+cands = []
+for f in glob.glob(os.path.join(PROJROOT, '**', '*.jsonl'), recursive=True):
+    try:
+        lines = open(f, errors='ignore').read().splitlines()
+    except Exception:
+        continue
+    prev_user = None
+    for l in lines:
+        if '"user"' not in l and '"assistant"' not in l:
+            continue
+        try:
+            d = json.loads(l)
+        except Exception:
+            continue
+        t, m = d.get('type'), d.get('message')
+        if not isinstance(m, dict):
+            continue
+        if t == 'user':
+            c = m.get('content')
+            if isinstance(c, str):
+                prev_user = c
+            elif isinstance(c, list):
+                txt = ' '.join(x.get('text', '') for x in c if isinstance(x, dict) and x.get('type') == 'text')
+                prev_user = txt or prev_user
+        elif t == 'assistant':
+            for it in (m.get('content') or []):
+                if isinstance(it, dict) and it.get('type') == 'text':
+                    tx = it.get('text', '')
+                    if len(tx) < 2500 and decline.search(tx) and reason.search(tx):
+                        cands.append({'file': os.path.basename(f), 'ts': d.get('timestamp'),
+                                      'user': (prev_user or '')[:600], 'refusal': tx[:800]})
+                        break
+
+json.dump(cands, open('/tmp/refusal_cands.json', 'w'), indent=1)
+print(f"CANDIDATES: {len(cands)} -> /tmp/refusal_cands.json")
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..0084840
--- /dev/null
+++ b/server.js
@@ -0,0 +1,54 @@
+#!/usr/bin/env node
+// Rejected-Prompts Viewer — zero-dependency http server.
+// Serves the viewer UI (Basic-auth gated) + the rejections dataset.
+// $0 local. Data is produced by build-data.py from Claude Code transcripts.
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+
+const PORT = process.env.PORT || 9858;
+const USER = process.env.BASIC_USER || 'admin';
+const PASS = process.env.BASIC_PASS || 'DW2024!';
+const ROOT = __dirname;
+
+const MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript', '.css': 'text/css',
+  '.json': 'application/json', '.svg': 'image/svg+xml' };
+
+function unauthorized(res) {
+  res.writeHead(401, { 'WWW-Authenticate': 'Basic realm="rejected-prompts"' });
+  res.end('Authentication required');
+}
+
+function checkAuth(req) {
+  const h = req.headers.authorization || '';
+  if (!h.startsWith('Basic ')) return false;
+  const [u, p] = Buffer.from(h.slice(6), 'base64').toString().split(':');
+  return u === USER && p === PASS;
+}
+
+const server = http.createServer((req, res) => {
+  if (!checkAuth(req)) return unauthorized(res);
+
+  const url = req.url.split('?')[0];
+
+  if (url === '/api/rejections') {
+    const f = path.join(ROOT, 'data', 'rejections.json');
+    fs.readFile(f, (err, buf) => {
+      if (err) { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end('{"generated":null,"items":[]}'); }
+      res.writeHead(200, { 'Content-Type': 'application/json' });
+      res.end(buf);
+    });
+    return;
+  }
+
+  // static
+  let rel = url === '/' ? '/index.html' : url;
+  const filePath = path.join(ROOT, 'public', path.normalize(rel).replace(/^(\.\.[/\\])+/, ''));
+  fs.readFile(filePath, (err, buf) => {
+    if (err) { res.writeHead(404); return res.end('Not found'); }
+    res.writeHead(200, { 'Content-Type': MIME[path.extname(filePath)] || 'application/octet-stream' });
+    res.end(buf);
+  });
+});
+
+server.listen(PORT, () => console.log(`rejected-prompts-viewer on http://127.0.0.1:${PORT}  (auth ${USER}/${PASS})`));

(oldest)  ·  back to Rejected Prompts Viewer  ·  Harden viewer: string-coerce esc(), fetch via location.origi 0c45511 →