Sequence writer
@gtmengineer/sequence-writer
Write multi-step outbound sequences (email, LinkedIn, call) built on a real reason to reach out, with personalization slots instead of fake personalization. A built-in linter blocks spam phrases, fake Re: subjects, invented familiarity, unsourced stats, broken slots, too many links, and rushed cadence before anything is loaded into a sending tool.
- Activity
- New: be the first to try it
- Installs
- 0 installs
- Likes
- 0 likes
- Clones
- 0 clones
- 1#!/usr/bin/env node
- 2// sequence-writer linter: check an outbound sequence (JSON) for spammy phrases,
- 3// unsupported claims, fake familiarity, broken personalization slots, length,
- 4// links, and cadence. Optional preview with one sample contact.
- 5// Node 22, built-ins only.
- 6//
- 7// Usage:
- 8// node scripts/lint-sequence.mjs <sequence.json> [--preview contact.json] [--strict] [--out out] [--name lint]
- 10import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
- 11import { join } from "node:path";
- 13function fail(msg) {
- 14 console.error(`sequence-writer: ${msg}`);
- 15 process.exit(1);
- 16}
- 18const args = process.argv.slice(2);
- 19const opts = { out: "out", name: "lint", preview: null, strict: false };
- 20const pos = [];
- 21for (let i = 0; i < args.length; i++) {
- 22 if (args[i] === "--out") opts.out = args[++i];
- 23 else if (args[i] === "--name") opts.name = args[++i];
- 24 else if (args[i] === "--preview") opts.preview = args[++i];
- 25 else if (args[i] === "--strict") opts.strict = true;
- 26 else if (args[i] === "--help" || args[i] === "-h") {
- 27 console.log("Usage: node scripts/lint-sequence.mjs <sequence.json> [--preview contact.json] [--strict] [--out out] [--name lint]");
- 28 process.exit(0);
- 29 } else pos.push(args[i]);
- 30}
- 31if (pos.length !== 1) fail("give one sequence JSON. Try --help.");
- 32const readJson = (p) => {
- 33 let t;
- 34 try {
- 35 t = readFileSync(p, "utf8");
- 36 } catch {
- 37 fail(`cannot read ${p}`);
- 38 }
- 39 try {
- 40 return JSON.parse(t);
- 41 } catch (e) {
- 42 fail(`${p} is not valid JSON: ${e.message}`);
- 43 }
- 44};
- 45const seq = readJson(pos[0]);
- 46if (!Array.isArray(seq.steps) || !seq.steps.length) fail("sequence needs a non-empty steps array");
- 47const limits = { email_words: 120, linkedin_chars: 300, subject_words: 6, max_steps: 8, ...(seq.limits || {}) };
- 48const declared = new Set(seq.slots || []);
- 50const SPAM = ["guarantee", "guaranteed", "risk-free", "risk free", "100% free", "act now", "limited time", "once in a lifetime", "no obligation", "click here", "buy now", "winner", "urgent", "cash bonus", "double your", "no strings attached", "free trial ends", "this isn't spam", "not spam", "dear sir", "dear madam", "to whom it may concern"];
- 51const HYPE = ["best-in-class", "best in class", "world-class", "world class", "industry-leading", "industry leading", "leading provider", "revolutionary", "cutting-edge", "game-changing", "game changer", "#1", "number one", "unmatched", "the only solution"];
- 52const FAKE = ["as promised", "per our conversation", "as we discussed", "following up on our call", "following up on our conversation", "you requested", "as you asked", "great chatting", "thanks for your interest"];
- 53const LAZY = ["just bumping", "bumping this up", "did you see my last", "circling back", "just checking in", "touching base", "any update", "last chance", "final notice", "breaking up with you"];
- 54const ACRONYM_OK = new Set(["CEO", "CFO", "CTO", "CRO", "COO", "CMO", "VP", "SDR", "BDR", "ARR", "MRR", "ROI", "SaaS", "API", "CRM", "GDPR", "HIPAA", "SOC", "ISO", "AWS", "B2B", "SMB", "ICP", "KPI", "OKR", "QBR", "RFP", "SLA", "USA", "EMEA", "APAC", "LATAM"].map((s) => s.toUpperCase()));
- 56const findAll = (text, list) => list.filter((p) => text.toLowerCase().includes(p));
- 57const words = (t) => (t.match(/\S+/g) || []).length;
- 59const report = [];
- 60let lastEmailDay = null;
- 61let lastDay = null;
- 62const usedSlots = new Set();
- 63seq.steps.forEach((s, idx) => {
- 64 const n = idx + 1;
- 65 const issues = [];
- 66 const add = (level, rule, text) => issues.push({ level, rule, text });
- 67 const channel = String(s.channel || "email").toLowerCase();
- 68 const subject = String(s.subject || "");
- 69 const body = String(s.body || "");
- 70 const all = `${subject}\n${body}`;
- 71 const day = Number(s.day);
- 73 if (!Number.isFinite(day)) add("error", "cadence", "step has no day number");
- 74 else {
- 75 if (lastDay != null && day < lastDay) add("error", "cadence", `day ${day} comes before the previous step (day ${lastDay})`);
- 76 if (channel === "email" && lastEmailDay != null && day - lastEmailDay < 2) add("warning", "cadence", `only ${day - lastEmailDay} day(s) after the previous email. Leave at least 2.`);
- 77 lastDay = day;
- 78 if (channel === "email") lastEmailDay = day;
- 79 }
- 80 if (!body.trim()) add("error", "empty", "step has no body");
- 82 // slots
- 83 const opens = (all.match(/\{\{/g) || []).length;
- 84 const closes = (all.match(/\}\}/g) || []).length;
- 85 if (opens !== closes) add("error", "slots", "unbalanced {{ }} braces");
- 86 const slots = [...all.matchAll(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g)].map((m) => m[1]);
- 87 for (const sl of slots) {
- 88 usedSlots.add(sl);
- 89 if (!declared.has(sl)) add("error", "slots", `{{${sl}}} is not in the sequence's slots list`);
- 90 }
- 91 if (/\[(?:first ?name|company|name|insert[^\]]*)\]|<(?:first ?name|company)>/i.test(all)) add("error", "slots", "placeholder in square or angle brackets. Use {{slot}} so it gets filled or flagged.");
- 92 if (channel === "email" && !s.in_thread && slots.filter((x) => !["first_name", "sender_name", "company"].includes(x)).length === 0)
- 93 add("warning", "personalization", "no personalization beyond name and company. Add a {{trigger}} or {{observation}} slot.");
- 95 // content rules
- 96 for (const p of findAll(all, SPAM)) add("error", "spam", `spam trigger phrase "${p}"`);
- 97 if (/\$\$|!!|\?\?/.test(all)) add("error", "spam", "repeated $ ! or ? characters");
- 98 if (/^\s*(re|fwd?)\s*:/i.test(subject) && !s.in_thread) add("error", "fake-familiarity", `subject starts with "${subject.split(":")[0]}:" but this is not a real reply`);
- 99 if (!s.prior_interaction) for (const p of findAll(all, FAKE)) add("error", "fake-familiarity", `"${p}" implies a conversation that did not happen`);
- 100 for (const p of findAll(all, LAZY)) add("warning", "lazy-follow-up", `"${p}": give a new reason to reply instead`);
- 101 const sources = Array.isArray(s.sources) && s.sources.length > 0;
- 102 if (!sources) {
- 103 for (const p of findAll(all, HYPE)) add("warning", "unsupported-claim", `"${p}" is a claim nobody believes without proof`);
- 104 const stats = all.match(/\b\d+(?:\.\d+)?\s?(?:%|x\b|percent\b|times\b)/gi) || [];
- 105 for (const st of new Set(stats)) add("warning", "unsupported-claim", `"${st.trim()}" needs a source in this step's sources list, or remove it`);
- 106 }
- 107 const caps = (all.match(/\b[A-Z]{4,}\b/g) || []).filter((w) => !ACRONYM_OK.has(w));
- 108 if (caps.length) add("warning", "shouting", `all-caps words: ${[...new Set(caps)].join(", ")}`);
- 109 if ((all.match(/!/g) || []).length > 1) add("warning", "shouting", "more than one exclamation mark");
- 110 const links = (body.match(/https?:\/\/\S+|www\.\S+/g) || []).length;
- 111 if (channel === "email" && links > 1) add("warning", "links", `${links} links. Use at most one.`);
- 112 if (channel === "email" && idx === 0 && links > 0) add("warning", "links", "link in the first email hurts deliverability. Save it for a later step.");
- 113 if (/\.(pdf|docx?|pptx?|zip)\b/i.test(body) && channel === "email" && idx === 0) add("warning", "attachments", "attachment mentioned in the first email");
- 115 // length and shape
- 116 if (channel === "email") {
- 117 const w = words(body);
- 118 if (w > limits.email_words) add("warning", "length", `${w} words. Keep cold emails under ${limits.email_words}.`);
- 119 if (!s.in_thread && !subject.trim()) add("error", "subject", "new email thread with no subject");
- 120 if (subject && words(subject) > limits.subject_words) add("warning", "subject", `subject is ${words(subject)} words. Keep it to ${limits.subject_words} or fewer.`);
- 121 if (subject && subject === subject.toUpperCase() && /[A-Z]/.test(subject)) add("error", "subject", "subject is all caps");
- 122 if (!body.includes("?")) add("info", "ask", "no question in the email. Is there one clear ask?");
- 123 }
- 124 if (channel === "linkedin" && body.length > limits.linkedin_chars) add("warning", "length", `${body.length} characters. LinkedIn connection notes are short: keep under ${limits.linkedin_chars}.`);
- 126 report.push({ step: n, day: Number.isFinite(day) ? day : null, channel, issues });
- 127});
- 129const seqIssues = [];
- 130if (seq.steps.length > limits.max_steps) seqIssues.push({ level: "warning", rule: "cadence", text: `${seq.steps.length} steps. More than ${limits.max_steps} touches rarely pays off.` });
- 131for (const sl of declared) if (!usedSlots.has(sl)) seqIssues.push({ level: "info", rule: "slots", text: `slot {{${sl}}} is declared but never used` });
- 132const emails = seq.steps.filter((s) => String(s.channel || "email").toLowerCase() === "email");
- 133if (emails.length && !emails.some((s) => /unsubscribe|opt[ -]?out|not the right person|not your area|i will stop|i'll stop|let me know if (?:you'd|you would) rather/i.test(String(s.body))))
- 134 seqIssues.push({ level: "warning", rule: "opt-out", text: "no step gives an easy way out (for example 'not the right person? tell me and I will stop'). Check the opt-out rules where you send." });
- 136const flat = [...seqIssues, ...report.flatMap((r) => r.issues)];
- 137const count = (lv) => flat.filter((i) => i.level === lv).length;
- 138const result = { sequence: seq.name || null, steps: seq.steps.length, errors: count("error"), warnings: count("warning"), info: count("info"), sequence_issues: seqIssues, step_issues: report };
- 140const md = [`# Sequence lint: ${seq.name || pos[0]}`, ""];
- 141md.push(`${result.errors} error(s), ${result.warnings} warning(s), ${result.info} note(s) across ${seq.steps.length} steps.`, "");
- 142for (const i of seqIssues) md.push(`- **${i.level.toUpperCase()}** (${i.rule}): ${i.text}`);
- 143if (seqIssues.length) md.push("");
- 144for (const r of report) {
- 145 md.push(`## Step ${r.step}: day ${r.day ?? "?"}, ${r.channel}`, "");
- 146 if (!r.issues.length) md.push("Clean.");
- 147 for (const i of r.issues) md.push(`- **${i.level.toUpperCase()}** (${i.rule}): ${i.text}`);
- 148 md.push("");
- 149}
- 151mkdirSync(opts.out, { recursive: true });
- 152writeFileSync(join(opts.out, `${opts.name}.json`), JSON.stringify(result, null, 2) + "\n");
- 153writeFileSync(join(opts.out, `${opts.name}.md`), md.join("\n"));
- 155if (opts.preview) {
- 156 const contact = readJson(opts.preview);
- 157 const missing = new Set();
- 158 const fill = (t) =>
- 159 String(t || "").replace(/\{\{\s*([a-zA-Z0-9_]+)\s*\}\}/g, (_, k) => {
- 160 if (contact[k] == null || contact[k] === "") {
- 161 missing.add(k);
- 162 return `[MISSING ${k}]`;
- 163 }
- 164 return String(contact[k]);
- 165 });
- 166 const pv = [`# Preview`, ""];
- 167 for (const [i, s] of seq.steps.entries()) {
- 168 pv.push(`## Step ${i + 1}: day ${s.day}, ${s.channel || "email"}`, "");
- 169 if (s.subject) pv.push(`**Subject:** ${fill(s.subject)}`, "");
- 170 pv.push(fill(s.body), "");
- 171 }
- 172 if (missing.size) pv.push(`Missing values: ${[...missing].join(", ")}. Do not send until they are filled or the sentence is removed.`, "");
- 173 writeFileSync(join(opts.out, `${opts.name}-preview.md`), pv.join("\n"));
- 174}
- 176console.log(`${result.errors} error(s), ${result.warnings} warning(s), ${result.info} note(s). Wrote ${join(opts.out, opts.name)}.{md,json}${opts.preview ? ` and ${opts.name}-preview.md` : ""}`);
- 177if (opts.strict && result.errors > 0) process.exit(1);
