commit 2099dbb15513be8d73c01d6f115a328f854910ce Author: Quince Date: Wed Jun 10 14:01:47 2026 +0200 lovelace v0.1 — lace-clad lead tracker for Glitch University outreach Standalone Python CLI + SQLite (stdlib only). Tracks creator leads, which letters went to which address, replies, and the funnel (prospect → … → live). Commands: init/add/list/show/set/note/sent/reply/next/stats/export. Lead data (emails/PII) stays local and is gitignored. Co-Authored-By: Claude Opus 4.8 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4e3f793 --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +# Lead data lives in SQLite and is NOT version-controlled (it holds emails / PII). +*.db +.lovelace/ + +# Python +__pycache__/ +*.pyc +*.egg-info/ +dist/ +build/ +venv/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..93446c8 --- /dev/null +++ b/README.md @@ -0,0 +1,53 @@ +# lovelace + +*A beautiful, enticing, lace-clad system for tracking creator leads — and the +letters we send to lure them into putting their content on Glitch University.* + +Named for Ada: the first programmer, in lace. **lovelace** is the Phase 3 engine +of the WP6 influencer campaign — it remembers **who we wrote to, at which address, +what we said, who wrote back, and how close each creator is to a live subtree.** + +No server, no dependencies — Python stdlib + a SQLite ledger. The lead data +(emails, PII) stays local and is never committed. + +## Install + +```bash +pip install -e . # then `lovelace ...` +# or, without installing: +python3 -m lovelace ... +``` + +The ledger lives at `~/.lovelace/lovelace.db` (override with `LOVELACE_DB`). + +## Use + +```bash +lovelace init # open the book + +lovelace add --name "Jane Deepdive" --platform youtube \ + --topic "heterodox economics" --email jane@example.com \ + --difficulty low --status shortlist --fit "long-form, paid audience" + +lovelace list # the whole book +lovelace list --status shortlist # filtered +lovelace next # who to approach next (easiest graft first) + +lovelace sent 1 --subject "Not a pitch..." --variant quince-A # log a letter +lovelace reply 1 "Intrigued — what's the split?" --disposition interested +lovelace show 1 # full history for one lead + +lovelace stats # the funnel +lovelace export --csv leads.csv # hand-off / backup +``` + +## The funnel + +`prospect → longlist → shortlist → contacted → replied → onboarding → live` +(plus `declined`). `sent` auto-advances a lead to *contacted*; `reply` to *replied*. + +## Roadmap + +- **Phase 3 (this):** lead DB + send/reply tracking + funnel. ✅ +- **Phase 4:** actually send the emails and handle replies — lovelace already + records the templates/variants so attribution is ready. diff --git a/lovelace/__init__.py b/lovelace/__init__.py new file mode 100644 index 0000000..3dc1f76 --- /dev/null +++ b/lovelace/__init__.py @@ -0,0 +1 @@ +__version__ = "0.1.0" diff --git a/lovelace/__main__.py b/lovelace/__main__.py new file mode 100644 index 0000000..d3fd62e --- /dev/null +++ b/lovelace/__main__.py @@ -0,0 +1,4 @@ +from lovelace.cli import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/lovelace/cli.py b/lovelace/cli.py new file mode 100644 index 0000000..8007869 --- /dev/null +++ b/lovelace/cli.py @@ -0,0 +1,325 @@ +"""lovelace — a lace-clad CLI for tracking creator leads and the letters sent to them. + +Ada would approve: the first programmer, dressed in lace. We track who we wrote to, +what we said, who wrote back, and how close each creator is to a live subtree. +""" +import argparse +import csv +import sys + +from . import db + +# ── lace ──────────────────────────────────────────────────────────────────── +_TTY = sys.stdout.isatty() +_C = {"grey": "90", "red": "31", "green": "32", "yellow": "33", "blue": "34", + "magenta": "35", "cyan": "36", "bgreen": "92", "bold": "1", "dim": "2"} + + +def c(s, *codes): + if not _TTY or not codes: + return s + return "\033[" + ";".join(_C[x] for x in codes) + "m" + str(s) + "\033[0m" + + +STATUS_COLOR = { + "prospect": "grey", "longlist": "blue", "shortlist": "cyan", + "contacted": "yellow", "replied": "magenta", "onboarding": "green", + "live": "bgreen", "declined": "red", +} + + +def st(status): + return c(status or "", STATUS_COLOR.get(status, "grey")) + + +def padc(text, width, *codes): + """Colorize then pad to a visible width (padding ignores ANSI codes).""" + text = (text or "")[:width] + return c(text, *codes) + " " * max(0, width - len(text)) + + +def banner(): + print(c(" ╭─◦❀◦───────────────────◦❀◦─╮", "magenta")) + print(c(" │", "magenta") + c(" L O V E L A C E", "bold") + c(" │", "magenta")) + print(c(" │", "magenta") + c(" lead tracking, lace-clad", "dim") + c(" │", "magenta")) + print(c(" ╰─◦❀◦───────────────────◦❀◦─╯", "magenta")) + + +# ── helpers ───────────────────────────────────────────────────────────────-- +def get_lead(conn, lead_id): + row = conn.execute("SELECT * FROM leads WHERE id=?", (lead_id,)).fetchone() + if not row: + sys.exit(c(f"No lead #{lead_id}. She is not in the book.", "red")) + return row + + +def touch(conn, lead_id): + conn.execute("UPDATE leads SET updated_at=? WHERE id=?", (db.now(), lead_id)) + + +# ── commands ──────────────────────────────────────────────────────────────-- +def cmd_init(args): + db.init_db() + banner() + print(c(" Book opened.", "green"), f"Ledger at {c(db.DB_PATH, 'dim')}") + + +def cmd_add(args): + conn = db.init_db() + cur = conn.execute( + "INSERT INTO leads(name,handle,email,platform,topic,audience,fit,difficulty,status,source,created_at,updated_at)" + " VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", + (args.name, args.handle, args.email, args.platform, args.topic, args.audience, + args.fit, args.difficulty, args.status, args.source, db.now(), db.now())) + conn.commit() + print(c(f" ✶ #{cur.lastrowid}", "bgreen"), c(args.name, "bold"), + "added as", st(args.status)) + + +def cmd_list(args): + conn = db.init_db() + q, params = "SELECT * FROM leads", [] + where = [] + if args.status: + where.append("status=?"); params.append(args.status) + if args.difficulty: + where.append("difficulty=?"); params.append(args.difficulty) + if where: + q += " WHERE " + " AND ".join(where) + q += " ORDER BY id" + rows = conn.execute(q, params).fetchall() + if not rows: + print(c(" The book is empty. Go find someone worth grafting.", "dim")) + return + print(c(" id name status diff platform email", "dim")) + print(c(" ─── ───────────────────── ─────────── ────── ─────────────── ─────────────────", "dim")) + for r in rows: + print(" " + padc(str(r["id"]), 5, "bold") + + padc(r["name"], 23) + + padc(r["status"], 13, STATUS_COLOR.get(r["status"], "grey")) + + padc(r["difficulty"], 8) + + padc(r["platform"], 17) + + (r["email"] or "")) + print(c(f"\n {len(rows)} lead(s).", "dim")) + + +def cmd_show(args): + conn = db.init_db() + r = get_lead(conn, args.id) + print() + print(" " + c(f"#{r['id']} {r['name']}", "bold") + f" {st(r['status'])}") + for label, key in [("handle", "handle"), ("email", "email"), ("platform", "platform"), + ("topic", "topic"), ("audience", "audience"), ("difficulty", "difficulty"), + ("source", "source"), ("fit", "fit")]: + if r[key]: + print(f" {c(label + ':', 'dim'):<14} {r[key]}") + notes = conn.execute("SELECT * FROM notes WHERE lead_id=? ORDER BY id", (r["id"],)).fetchall() + if notes: + print(c("\n notes", "dim")) + for n in notes: + print(f" {c(n['created_at'], 'dim')} {n['body']}") + out = conn.execute("SELECT * FROM outreach WHERE lead_id=? ORDER BY id", (r["id"],)).fetchall() + if out: + print(c("\n letters sent", "dim")) + for o in out: + v = f" [{o['variant']}]" if o["variant"] else "" + print(f" {c(o['sent_at'], 'dim')} → {c(o['to_address'] or '?', 'yellow')}{v} \"{o['subject'] or ''}\"") + rep = conn.execute("SELECT * FROM replies WHERE lead_id=? ORDER BY id", (r["id"],)).fetchall() + if rep: + print(c("\n replies", "dim")) + for rp in rep: + d = c(rp["disposition"] or "", "magenta") + print(f" {c(rp['received_at'], 'dim')} {d} {(rp['body'] or '')[:80]}") + print() + + +def cmd_set(args): + conn = db.init_db() + get_lead(conn, args.id) + fields = {k: getattr(args, k) for k in + ("name", "handle", "email", "platform", "topic", "audience", "fit", "difficulty", "status", "source") + if getattr(args, k) is not None} + if args.status and args.status not in db.STATUSES: + sys.exit(c(f"Unknown status '{args.status}'. Pick from: {', '.join(db.STATUSES)}", "red")) + if not fields: + sys.exit(c("Nothing to set. Pass --status / --email / etc.", "red")) + sets = ", ".join(f"{k}=?" for k in fields) + ", updated_at=?" + conn.execute(f"UPDATE leads SET {sets} WHERE id=?", (*fields.values(), db.now(), args.id)) + conn.commit() + print(c(f" #{args.id} updated:", "green"), ", ".join(f"{k}={v}" for k, v in fields.items())) + + +def cmd_note(args): + conn = db.init_db() + get_lead(conn, args.id) + conn.execute("INSERT INTO notes(lead_id,body,created_at) VALUES(?,?,?)", + (args.id, args.text, db.now())) + touch(conn, args.id) + conn.commit() + print(c(f" noted on #{args.id}.", "green")) + + +def cmd_sent(args): + conn = db.init_db() + lead = get_lead(conn, args.id) + to = args.to or lead["email"] + if not to: + sys.exit(c("No address. Pass --to or set the lead's email first.", "red")) + conn.execute( + "INSERT INTO outreach(lead_id,channel,to_address,subject,variant,template,sent_at,status,notes)" + " VALUES(?,?,?,?,?,?,?,?,?)", + (args.id, args.channel, to, args.subject, args.variant, args.template, db.now(), "sent", args.notes)) + # advance the funnel if this is the first contact + if lead["status"] in ("prospect", "longlist", "shortlist"): + conn.execute("UPDATE leads SET status='contacted' WHERE id=?", (args.id,)) + touch(conn, args.id) + conn.commit() + print(c(f" ✉ letter logged", "yellow"), "to", c(to, "bold"), + f"(#{args.id} {lead['name']})", "→", st("contacted")) + + +def cmd_reply(args): + conn = db.init_db() + lead = get_lead(conn, args.id) + conn.execute( + "INSERT INTO replies(lead_id,outreach_id,received_at,disposition,body,handled)" + " VALUES(?,?,?,?,?,0)", + (args.id, args.outreach, db.now(), args.disposition, args.text)) + if lead["status"] in ("contacted", "longlist", "shortlist", "prospect"): + conn.execute("UPDATE leads SET status='replied' WHERE id=?", (args.id,)) + touch(conn, args.id) + conn.commit() + d = c(args.disposition, "magenta") if args.disposition else "" + print(c(f" ✎ reply logged", "magenta"), f"from #{args.id} {lead['name']}", d, "→", st("replied")) + + +def cmd_next(args): + conn = db.init_db() + rows = conn.execute( + "SELECT * FROM leads WHERE status IN ('shortlist','longlist')" + " AND id NOT IN (SELECT lead_id FROM outreach)" + " ORDER BY CASE difficulty WHEN 'low' THEN 0 WHEN 'med' THEN 1 WHEN 'high' THEN 2 ELSE 3 END," + " CASE status WHEN 'shortlist' THEN 0 ELSE 1 END, id LIMIT ?", (args.limit,)).fetchall() + if not rows: + print(c(" No one waiting. Longlist some creators, or go plant a subtree.", "dim")) + return + print(c(" next to approach (easiest graft first):", "dim")) + for r in rows: + print(" " + c(f"#{r['id']}", "bold") + f" {r['name']:<22} {st(r['status'])} " + + c(f"graft:{r['difficulty']}", "dim") + f" {r['platform'] or ''}") + + +def cmd_stats(args): + conn = db.init_db() + banner() + total = conn.execute("SELECT COUNT(*) FROM leads").fetchone()[0] + if not total: + print(c("\n No leads yet. The lace is laid; now bring the creators.", "dim")) + return + print(c("\n funnel", "dim")) + counts = {s: 0 for s in db.STATUSES} + for row in conn.execute("SELECT status, COUNT(*) n FROM leads GROUP BY status"): + counts[row["status"]] = row["n"] + width = 28 + for s in db.STATUSES: + n = counts.get(s, 0) + bar = "▰" * min(n, width) + print(f" {st(s):<19} {c(str(n), 'bold'):>3} {c(bar, STATUS_COLOR.get(s, 'grey'))}") + sent = conn.execute("SELECT COUNT(DISTINCT lead_id) FROM outreach").fetchone()[0] + replied = conn.execute("SELECT COUNT(DISTINCT lead_id) FROM replies").fetchone()[0] + rate = (100.0 * replied / sent) if sent else 0.0 + print(c("\n campaign", "dim")) + print(f" {c('total leads', 'dim'):<22} {total}") + print(f" {c('contacted', 'dim'):<22} {sent}") + print(f" {c('replied', 'dim'):<22} {replied} ({c(f'{rate:.0f}%', 'bold')} response)") + + +def cmd_export(args): + conn = db.init_db() + rows = conn.execute("SELECT * FROM leads ORDER BY id").fetchall() + cols = ["id", "name", "handle", "email", "platform", "topic", "audience", + "fit", "difficulty", "status", "source", "created_at", "updated_at"] + out = open(args.csv, "w", newline="") if args.csv else sys.stdout + w = csv.writer(out) + w.writerow(cols) + for r in rows: + w.writerow([r[col] for col in cols]) + if args.csv: + out.close() + print(c(f" exported {len(rows)} lead(s) → {args.csv}", "green")) + + +# ── parser ────────────────────────────────────────────────────────────────-- +def build_parser(): + p = argparse.ArgumentParser(prog="lovelace", description="Lace-clad lead tracking for Glitch University outreach.") + sub = p.add_subparsers(dest="command") + + sub.add_parser("init", help="Create the ledger.").set_defaults(func=cmd_init) + + a = sub.add_parser("add", help="Add a lead.") + a.add_argument("--name", required=True) + for f in ("handle", "email", "platform", "topic", "audience", "fit", "source"): + a.add_argument(f"--{f}") + a.add_argument("--difficulty", default="unknown", choices=["low", "med", "high", "unknown"]) + a.add_argument("--status", default="prospect", choices=db.STATUSES) + a.set_defaults(func=cmd_add) + + li = sub.add_parser("list", help="List leads.") + li.add_argument("--status", choices=db.STATUSES) + li.add_argument("--difficulty", choices=["low", "med", "high", "unknown"]) + li.set_defaults(func=cmd_list) + + sh = sub.add_parser("show", help="Show one lead in full.") + sh.add_argument("id", type=int) + sh.set_defaults(func=cmd_show) + + se = sub.add_parser("set", help="Update lead fields.") + se.add_argument("id", type=int) + for f in ("name", "handle", "email", "platform", "topic", "audience", "fit", "difficulty", "status", "source"): + se.add_argument(f"--{f}") + se.set_defaults(func=cmd_set) + + no = sub.add_parser("note", help="Add a note to a lead.") + no.add_argument("id", type=int) + no.add_argument("text") + no.set_defaults(func=cmd_note) + + sn = sub.add_parser("sent", help="Log a letter sent to a lead's address.") + sn.add_argument("id", type=int) + sn.add_argument("--to", help="Address (defaults to the lead's email).") + sn.add_argument("--subject") + sn.add_argument("--variant", help="A/B variant tag (subject/sender).") + sn.add_argument("--template") + sn.add_argument("--channel", default="email") + sn.add_argument("--notes") + sn.set_defaults(func=cmd_sent) + + rp = sub.add_parser("reply", help="Log a reply from a lead.") + rp.add_argument("id", type=int) + rp.add_argument("text") + rp.add_argument("--disposition", choices=["interested", "maybe", "question", "declined"]) + rp.add_argument("--outreach", type=int, help="Which outreach id this answers.") + rp.set_defaults(func=cmd_reply) + + nx = sub.add_parser("next", help="Who to approach next (easiest graft first).") + nx.add_argument("--limit", type=int, default=10) + nx.set_defaults(func=cmd_next) + + sub.add_parser("stats", help="Campaign funnel.").set_defaults(func=cmd_stats) + + ex = sub.add_parser("export", help="Export leads to CSV.") + ex.add_argument("--csv", help="File path (default: stdout).") + ex.set_defaults(func=cmd_export) + + return p + + +def main(argv=None): + parser = build_parser() + args = parser.parse_args(argv) + if not getattr(args, "command", None): + banner() + parser.print_help() + return 0 + args.func(args) + return 0 diff --git a/lovelace/db.py b/lovelace/db.py new file mode 100644 index 0000000..7b20213 --- /dev/null +++ b/lovelace/db.py @@ -0,0 +1,84 @@ +"""lovelace data layer — SQLite, stdlib only.""" +import os +import pathlib +import sqlite3 +from datetime import datetime, timezone + +DEFAULT_DIR = pathlib.Path(os.environ.get("LOVELACE_HOME", pathlib.Path.home() / ".lovelace")) +DB_PATH = pathlib.Path(os.environ.get("LOVELACE_DB", DEFAULT_DIR / "lovelace.db")) + +# Funnel stages, in order. A lead's status is one of these. +STATUSES = [ + "prospect", # spotted, not yet vetted + "longlist", # worth approaching + "shortlist", # top picks for the launch cohort + "contacted", # a letter has gone out + "replied", # they wrote back + "onboarding", # grafting their subtree + "live", # subtree is up + "declined", # not interested / not a fit +] + +SCHEMA = """ +CREATE TABLE IF NOT EXISTS leads ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + handle TEXT, + email TEXT, + platform TEXT, + topic TEXT, + audience TEXT, + fit TEXT, + difficulty TEXT DEFAULT 'unknown', + status TEXT DEFAULT 'prospect', + source TEXT, + created_at TEXT, + updated_at TEXT +); +CREATE TABLE IF NOT EXISTS outreach ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lead_id INTEGER NOT NULL REFERENCES leads(id) ON DELETE CASCADE, + channel TEXT DEFAULT 'email', + to_address TEXT, + subject TEXT, + variant TEXT, + template TEXT, + sent_at TEXT, + status TEXT DEFAULT 'sent', + notes TEXT +); +CREATE TABLE IF NOT EXISTS replies ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lead_id INTEGER NOT NULL REFERENCES leads(id) ON DELETE CASCADE, + outreach_id INTEGER REFERENCES outreach(id) ON DELETE SET NULL, + received_at TEXT, + disposition TEXT, + body TEXT, + handled INTEGER DEFAULT 0 +); +CREATE TABLE IF NOT EXISTS notes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + lead_id INTEGER NOT NULL REFERENCES leads(id) ON DELETE CASCADE, + body TEXT, + created_at TEXT +); +""" + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M:%S UTC") + + +def connect(): + DB_PATH.parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(str(DB_PATH)) + conn.row_factory = sqlite3.Row + conn.execute("PRAGMA foreign_keys = ON") + return conn + + +def init_db(): + conn = connect() + conn.executescript(SCHEMA) + conn.commit() + return conn diff --git a/setup.py b/setup.py new file mode 100644 index 0000000..bcea3b6 --- /dev/null +++ b/setup.py @@ -0,0 +1,11 @@ +from setuptools import setup, find_packages + +setup( + name="lovelace", + version="0.1.0", + description="Lace-clad CLI for tracking creator leads and outreach for Glitch University.", + packages=find_packages(), + install_requires=[], # stdlib only + entry_points={"console_scripts": ["lovelace=lovelace.cli:main"]}, + python_requires=">=3.9", +)