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 <noreply@anthropic.com>
This commit is contained in:
Quince
2026-06-10 14:01:47 +02:00
commit 2099dbb155
7 changed files with 489 additions and 0 deletions
+11
View File
@@ -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/
+53
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
__version__ = "0.1.0"
+4
View File
@@ -0,0 +1,4 @@
from lovelace.cli import main
if __name__ == "__main__":
raise SystemExit(main())
+325
View File
@@ -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
+84
View File
@@ -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
+11
View File
@@ -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",
)