Server operations
Every automation running on the server, what it does, when it runs and whether it worked. Built from the crontab itself, so anything added later appears here on its own.
| Check | Findings |
|---|---|
credentials a key that has gone missing | 0 |
cron -> script a cron pointing at a file that is gone | 0 |
staleness a job that stopped, or last failed | 0 |
silent success OK, while saying it did nothing | 0 |
exit integrity success reported on a failure path | 0 |
deploy parity repo and server disagree | COULD NOT CHECK |
imports a module the venv does not have | 0 |
log errors tracebacks / auth failures nobody saw | 22 |
cron collisions two jobs in the same minute | 1 |
duplicate crons the same line entered twice | 0 |
AUDIT: 15 finding(s), 2 that cost money, 1 blind spot(s)
| Automation | Runs | Last run | Next | State |
|---|---|---|---|---|
"where are we?" for every client onboarding, and TICK what is provably… onbstatus · onb_status.py Two jobs: 1. STATUS — a per-stage progress view per client, read straight from ClickUp. 2. VERIFY-TICK — for the steps whose completion can be PROVEN from the live GHL sub-account, check the real state and close the ClickUp task, leaving th What it does, in fullonb_status.py — "where are we?" for every client onboarding, and TICK what is provably done. Two jobs: 1. STATUS — a per-stage progress view per client, read straight from ClickUp. 2. VERIFY-TICK — for the steps whose completion can be PROVEN from the live GHL sub-account, check the real state and close the ClickUp task, leaving the evidence as a comment. Rule (Kirk 2026-08-08): only ever tick what can be verified. Every auto-tick here re-reads the live system first and records what it saw, so a green check in ClickUp always means the thing is actually true. Never ticks on assumption, never ticks a step it merely attempted. Usage: python3 onb_status.py # status for every client python3 onb_status.py --client Inspect # one client python3 onb_status.py --client Inspect --tick --live # verify + close proven tasks cron 25 8,17 * * * /usr/bin/flock -n /tmp/onb_status.lock /opt/ghl/run.sh onbstatus onb_status.py --tick --live >> /opt/ghl/logs/onboard.log 2>&1 # verify-and-tick proven onboarding steps + progress view (Kirk 2026-08-09) log /opt/ghl/logs/ · status /opt/ghl/status/onbstatus.last | 2x a day (8am-5pm) | 8h ago | Tue 17:25 | OK |
4-way SMS WRAPPER A/B split-test tracker (Kirk 2026-07-21) abtest · ab_test_tracker.py PER-LEAD, tag-driven. Each batch is SPLIT across wrappers (never one whole batch per wrapper), so wrappers fight on the same terrain — same cities, day, list. Your send process tags each lead with the wrapper it got: sms-test-v1 … sms-test- What it does, in fullab_test_tracker.py — 4-way SMS WRAPPER A/B split-test tracker (Kirk 2026-07-21). PER-LEAD, tag-driven. Each batch is SPLIT across wrappers (never one whole batch per wrapper), so wrappers fight on the same terrain — same cities, day, list. Your send process tags each lead with the wrapper it got: sms-test-v1 … sms-test-v4. This counts the full funnel PER WRAPPER by TAG INTERSECTION (cheap + consistent): Total Sent = # of that version's leads whose OPENER actually DELIVERED (Sendivo, same 'successful send' as the Metric Tracker — rejected/undelivered excluded), NOT the tagged-lead count (which inflated the denominator + deflated every rate) Total Replies = variation ∩ ('cold sms-contact replied' ∪ 'cold sms-called in') RR > PRR = Positive ÷ Replies (of those who replied, % positive · KPI 25%) Positive Replies = variation ∩ 'cold sms-positive' (SAME strict classifier as the batch tracker — direct-intent/questions — tagged live by the responder) PRR > ABR = Booked ÷ Positive (of positives, % booked · KPI 60%) Total Engaged = variation ∩ 'cold sms-engaged lead' (positive + ambiguous) Calls Booked = variation ∩ (appointment booked / won) Shows = variation leads' phones ∩ shown growth appts (Show + Bad-fit) Closes = variation leads ∩ Cash Collected > 0 the %/rate rows are sheet formulas · Script + Cash Collected are yours to type RR>PRR + PRR>ABR are computed EXACTLY like the Metric Tracker main sheet (connect_rate.py). Cron abtest 06:40 daily. Positive/engaged tags are applied live going forward, so a wrapper test on upcoming batches is fully covered. cron 40 6 * * * /opt/ghl/run.sh abtest ab_test_tracker.py >> /opt/ghl/logs/abtest.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/abtest.last | daily at 6am:40 | 10h ago | Wed 06:40 | OK |
a homeowner cancels on a Nedzo call; the ROOFER hears about it, with the… cancelwatch · cancel_watch.py Kirk 2026-08-20, after doing it by hand: Bill Butler called in and cancelled Friday's inspection because Allstate had already told him he did not need a replacement. Nedzo cancelled the appointment correctly — and nobody told Tom. Kirk wrot What it does, in fullcancel_watch.py — a homeowner cancels on a Nedzo call; the ROOFER hears about it, with the reason. Kirk 2026-08-20, after doing it by hand: Bill Butler called in and cancelled Friday's inspection because Allstate had already told him he did not need a replacement. Nedzo cancelled the appointment correctly — and nobody told Tom. Kirk wrote and sent that message himself, including the roof context, and asked for it to stop being manual. WHAT FIRES IT An appointment that goes to `cancelled` AND has a Nedzo call summary explaining why. That pairing is deliberate: appt_dedupe cancels the self-booked placeholder on essentially every booked lead and writes no summary, so those stay silent without needing a special case. No reason on file = no message; a roofer told "cancelled, no idea why" is worse than not being told. ⛔ THE SUMMARY IS DESTROYED BY THE CANCEL CALL Nedzo overwrites Homeowner Summary with the CANCELLATION narrative, so by the time we notice, the reason the homeowner was ever worth visiting — 30-year-old roof, active leak, 1,800 sq ft — is gone. That context is most of the value of the message: it is what lets a roofer judge whether a second opinion is worth the drive. So every pass ARCHIVES the summary it sees, and the message quotes the newest archived version that predates the cancellation. Verified on Jane Butler: the original was already unrecoverable, nowhere on disk. SAFETY • dry run is the default; --live is required to send anything • --seed marks today's cancellations handled WITHOUT sending, so switching this on does not text a client about appointments already dealt with by hand (Kirk texted Tom about Jane himself) • state is flushed the instant a message lands, never at the end of the run • the script holds its own lock, so a hand-run cannot race the cron and double-send • Kirk is copied on every message, so nothing reaches a client unseen Usage: python3 cancel_watch.py # dry run python3 cancel_watch.py --seed --live # ONCE: adopt existing cancellations silently python3 cancel_watch.py --live # normal (cron) cron 10,35 7-21 * * * /usr/bin/flock -n /tmp/cancel_watch.cron.lock /opt/ghl/run.sh cancelwatch cancel_watch.py --live >> /opt/ghl/logs/cancel_watch.log 2>&1 # homeowner cancels on a Nedzo call -> text the ROOFER the reason + the booking-call context + "still want to give a second opinion?" (Kirk 2026-08-20, after sending one by hand). Only fires when the AI recorded a REASON, so appt_dedupe clearing a placeholder stays silent. Kirk is copied on every send. Minutes 10/35 are the only two that dodge every existing series (/3 zoomwatch, /5, /10, /15, /30 and the explicit ones). log /opt/ghl/logs/ · status /opt/ghl/status/cancelwatch.last | every 25 min, 7am-9pm | 32 min ago | Tue 17:10 | OK |
a homeowner texts "I need to reschedule", and we handle the whole thing reschedulebot · reschedule_bot.py Kirk 2026-08-17: "the closebot ai not doing it well... there's conflicting issues... i need you to take that over fully." So this owns the conversation end to end rather than nudging a bot: What it does, in fullreschedule_bot.py — a homeowner texts "I need to reschedule", and we handle the whole thing. Kirk 2026-08-17: "the closebot ai not doing it well... there's conflicting issues... i need you to take that over fully." So this owns the conversation end to end rather than nudging a bot: they text asking to move it -> cancel the appointment they already have, if it is still in the future -> read TWO real openings off the client's reschedule calendar -> text both and ask which -> they pick one -> book it on the reschedule calendar and confirm WHY IT IS SAFE TO RUN UNATTENDED. Cancelling a real homeowner's appointment is destructive and irreversible from their point of view, so every gate below has to pass before anything moves: - the contact must carry the "inspection booked" tag. A stray "can we reschedule?" from someone with no appointment does nothing at all. - the appointment must be in the FUTURE. We never touch history. - the intent is judged by Opus, not by keyword matching. "do I need to reschedule?" and "I rescheduled my other appointment" both contain the word and neither is a request. See skill: automation-integrity. - nothing is cancelled until two real slots have been found. Cancelling and then discovering the calendar is empty leaves a homeowner with nothing, which is worse than the original problem. - texts only go out inside business hours, and every send is recorded before the next step. ⛔ NEVER tell a homeowner they do not need to be present. Kirk, standing rule: we want them at the property for the inspection. python3 reschedule_bot.py # show what it would do, change nothing python3 reschedule_bot.py --live python3 reschedule_bot.py --seed # mark current messages as seen, act on none See skills: no-silent-failures, supervised-release, send-safety, timezone-day-attribution. cron 7-52/15 7-21 * * * /opt/ghl/run.sh reschedulebot reschedule_bot.py --live >> /opt/ghl/logs/reschedule_bot.log 2>&1 # homeowner texts asking to move their inspection -> cancel + offer two real openings + rebook (Kirk 2026-08-17). HELD per supervised-release: it CANCELS live appointments, so it does not run autonomously until Kirk has watched one real reschedule end to end. Minutes 7,22,37,52 dodge every existing series; 7-21 only, so nobody is texted overnight. log /opt/ghl/logs/ · status /opt/ghl/status/reschedulebot.last | every 15 min, 7am-9pm | 15 min ago | Tue 17:22 | OK |
a signed client goes from a ClickUp row to a working sub-account,… pipeline · onboard_pipeline.py Kirk 2026-08-09: "put the new client on cron too.. when a new client is signed it runs the pipeline automatically." What it does, in fullonboard_pipeline.py — a signed client goes from a ClickUp row to a working sub-account, unattended. Kirk 2026-08-09: "put the new client on cron too.. when a new client is signed it runs the pipeline automatically." A new row in the ClickUp table IS the signal — Make writes it the moment the onboarding Google Form is submitted, so a row that this has never seen is a newly signed client. THE STEPS, in the only order that works: 1. create_subaccount POST /locations/ with snapshotId — the account AND the whole B2C build in one call (agency token; per-location PITs 401 here) 2. customizer MUST come after 1: a snapshot push WIPES custom values, so customising first would silently undo itself 3. client_dropbox the shared assets folder (built fresh, never duplicated — the real client folders hold their own logos/photos) 4. client_roi_sheet the ROI tracker, linked back into the sub-account + ClickUp 5. brevo_setup --ensure UI automation, falling back to posting the paste-card 6. onb_status --tick closes only what can be PROVEN from live GHL state (the welcome card has its own cron and its own double-send guard, so it is left alone) SAFETY — this spends money and creates real infrastructure: • --live AND --yes are both required; without them it prints the plan and stops. • SEEDING: every client row present when this was switched on is recorded as skipped, exactly like the gift card. Without that, the first run would have tried to build sub-accounts for five historical clients. • Per-client, per-step state: a step that succeeded never re-runs, so a failure halfway through resumes instead of starting over. • A step that fails STOPS that client's pipeline — later steps assume earlier ones landed. • Any failure exits nonzero so run.sh pages Kirk. Usage: python3 onboard_pipeline.py --seed-existing # once, before enabling python3 onboard_pipeline.py # dry run — show what it would do python3 onboard_pipeline.py --live --yes # the cron form cron 20 8-20 * * * /usr/bin/flock -n /tmp/onboard_pipeline.lock /opt/ghl/run.sh pipeline onboard_pipeline.py --live --yes >> /opt/ghl/logs/pipeline.log 2>&1 # a signed client goes from ClickUp row to working sub-account unattended (Kirk 2026-08-09). Pre-existing rows seeded as skipped so it can never rebuild old clients; per-step state so a failure resumes; exits nonzero so run.sh pages Kirk. log /opt/ghl/logs/ · status /opt/ghl/status/pipeline.last | 13x a day (8am-8pm) | 47 min ago | Tue 17:20 | OK |
after a HOMEOWNER books an on-site inspection, text them in the CLIENT's b2cpostbook · b2c_post_booking.py voice: who is coming, why being there is worth it, and two real things past customers said. What it does, in fullb2c_post_booking.py — after a HOMEOWNER books an on-site inspection, text them in the CLIENT's voice: who is coming, why being there is worth it, and two real things past customers said. The B2C twin of the B2B post_booking_research.py. Kirk approved the wording line by line (2026-08-15); it lives in config/b2c_post_booking.json, NOT in this file, so the copy can be corrected without touching code. Do not reword it here. WHAT THIS GETS RIGHT ON PURPOSE • {when} is rendered in the HOMEOWNER's timezone. GHL returns startTime as a NAIVE string ("2026-08-17 11:30:00") with no zone, so it is read in the CLIENT LOCATION's timezone and converted. A homeowner told the wrong hour is simply not home when the crew arrives — and the B2B sender already learned this when a California prospect got a Central time. If the homeowner has no timezone, the TIME IS DROPPED rather than guessed. • Content per client (company/owner/area/years/quotes) comes from the client's own site and listing, never from asking the roofer. Missing pieces DEGRADE the message (see _fallbacks) rather than inventing a claim. • Seeded on first run so switching it on cannot text every past booking. • One send per appointment, ever. Opt-out and DND respected. Usage: python3 b2c_post_booking.py # dry run: show exactly what would send python3 b2c_post_booking.py --seed # baseline existing bookings, send nothing python3 b2c_post_booking.py --live cron 6-56/10 7-21 * * * /usr/bin/flock -n /tmp/b2c_post_booking.lock /opt/ghl/run.sh b2cpostbook b2c_post_booking.py --live >> /opt/ghl/logs/b2c_post_booking.log 2>&1 # homeowner books an on-site inspection -> two texts in the CLIENT's voice (Kirk-approved copy in config/b2c_post_booking.json). Seeded, one send per appointment ever, DND respected, {when} in the HOMEOWNER's timezone. log /opt/ghl/logs/ · status /opt/ghl/status/b2cpostbook.last | every 10 min, 7am-9pm | just now | Tue 17:16 | OK |
appointment_reminder.py appt-night · appointment_reminder.py Growth-appointment reminders for the RidgeRev (main sales) sub-account. Two modes: What it does, in fullappointment_reminder.py Growth-appointment reminders for the RidgeRev (main sales) sub-account. Two modes: --mode night (7 PM CST): text every prospect with an appointment TOMORROW a confirmation asking them to confirm their email for the gift. --mode morning (9 AM CST): for every prospect with an appointment TODAY who did NOT reply to the night-before confirmation, send a double-confirm. Only growth-session calendars (not client success calls). Skips cancelled/no-show. DEDUP GUARD everywhere: never sends the same message twice (skips contacts already sent that message in the last 20h, including Kirk's manual sends). Usage: python3 scripts/appointment_reminder.py --mode night python3 scripts/appointment_reminder.py --mode morning python3 scripts/appointment_reminder.py --mode morning --date 2026-07-06 --dry-run cron 2 19 * * * /opt/ghl/run.sh appt-night appointment_reminder.py --mode night >> /opt/ghl/logs/appt.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/appt-night.last | daily at 7pm:02 | 22h ago | Tue 19:02 | OK |
appointment_reminder.py appt-morning · appointment_reminder.py Growth-appointment reminders for the RidgeRev (main sales) sub-account. Two modes: What it does, in fullappointment_reminder.py Growth-appointment reminders for the RidgeRev (main sales) sub-account. Two modes: --mode night (7 PM CST): text every prospect with an appointment TOMORROW a confirmation asking them to confirm their email for the gift. --mode morning (9 AM CST): for every prospect with an appointment TODAY who did NOT reply to the night-before confirmation, send a double-confirm. Only growth-session calendars (not client success calls). Skips cancelled/no-show. DEDUP GUARD everywhere: never sends the same message twice (skips contacts already sent that message in the last 20h, including Kirk's manual sends). Usage: python3 scripts/appointment_reminder.py --mode night python3 scripts/appointment_reminder.py --mode morning python3 scripts/appointment_reminder.py --mode morning --date 2026-07-06 --dry-run cron 3 9 * * * /opt/ghl/run.sh appt-morning appointment_reminder.py --mode morning >> /opt/ghl/logs/appt.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/appt-morning.last | daily at 9am:03 | 8h ago | Wed 09:03 | OK |
auto-recover cold-SMS sends that FAILED (e.g sendivo-resend · sendivo_resend.py marks the message 'failed', and never retries — so leads silently miss their texts). Runs on a cron, finds failed outbound SMS, and RESENDS them once Sendivo is back. What it does, in fullsendivo_resend.py — auto-recover cold-SMS sends that FAILED (e.g. during a Sendivo outage: GHL can't relay, marks the message 'failed', and never retries — so leads silently miss their texts). Runs on a cron, finds failed outbound SMS, and RESENDS them once Sendivo is back. Safe by design: • ONLY resends when Sendivo is reachable (else it would fail again — skip + retry next cycle). • GROUND-TRUTH DEDUP (2026-08-05, after resends spammed duplicates to live leads): GHL status='failed' is UNRELIABLE during a Sendivo timeout — GHL marks messages failed that ACTUALLY delivered. So before resending we cross-check Sendivo's OWN /sms/logs (to_number + message_content). If Sendivo has that (phone, body), it went out → SKIP. We only resend messages Sendivo never received. This is what stops the duplicates; the (contact+text) state set below is a secondary guard against re-send loops. • NATURAL CADENCE: a contact's genuinely-dropped messages are resent IN ORDER, spaced ~SPACING_S apart (not a 1.5s dump), so a lead never gets 3 texts in one burst. Run is time-capped so a big backlog is split across cycles rather than overrunning the cron window. • Skips opt-outs (DND). Pages Kirk when it resends (no-silent-failures). Kirk 2026-08-04 (built), 2026-08-05 (dedup + cadence fix after live-lead duplicates). cron 9,39 7-21 * * * RESEND_LOOKBACK_H=3 /opt/ghl/run.sh sendivo-resend sendivo_resend.py >> /opt/ghl/logs/sendivo_resend.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/sendivo-resend.last | every 30 min, 7am-9pm | 25 min ago | Tue 17:09 | OK |
batch_uploader.py batch · batch_uploader.py Enrolls cold-SMS batches into the sending workflow. Replaces Kirk's manual GHL bulk-action process (handed over July 5, 2026). What it does, in fullbatch_uploader.py Enrolls cold-SMS batches into the sending workflow. Replaces Kirk's manual GHL bulk-action process (handed over July 5, 2026). Timing is handled ENTIRELY by the workflow itself, NOT at enrollment: 'Cold SMS - Easy Grow- SMS Scaling System v1' starts with Wait(9:30 AM, Mon-Fri) -> If/Else(time <= 2:30 PM) -> Drip(2/min). So contacts added ANY time just pool at the Wait step and release at max 300/weekday inside the 9:30 AM - 2:30 PM window. (eventStartTime does NOT defer execution — proven July 5 incident — so we never use it. Plain adds only.) State lives in the Kirk-visible 'Batch Queue' tab of the SMS Tracking sheet (atomic writes — no git-commit race, so a batch can never double-enroll): A Batch | B Tag | C Start Date (YYYY-MM-DD) | D Contacts | E Enrolled? | F Enrolled At Kirk edits this tab to add future batches / adjust dates. The routine reads it. Modes: --auto daily 2 AM routine: enroll any batch due today, warn if low --batch N --tag T manual one-off enrollment of a specific batch --init-queue --seed f (re)build the Batch Queue tab from a seed file cron 0 2 * * * /opt/ghl/run.sh batch batch_uploader.py --auto >> /opt/ghl/logs/batch.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/batch.last | daily at 2am:00 | 14h ago | Wed 02:00 | OK |
build a client's onboarding row in ClickUp from the template onboardrow · onboard_row.py Kirk 2026-08-23: "I need you to take over the entire process of when somebody fills out the Google Form ... create a copy of the template which is row 1 in the client onboarding table with all of the subtasks from that template with all of What it does, in fullonboard_row.py — build a client's onboarding row in ClickUp from the template. Kirk 2026-08-23: "I need you to take over the entire process of when somebody fills out the Google Form ... create a copy of the template which is row 1 in the client onboarding table with all of the subtasks from that template with all of the information in the relevant columns." This replaces the MAKE scenario that owned intake. It broke on Atlas Roofing Management LLC: the row was created with the client's data in the columns and NONE of the 200 subtasks. A row with no subtasks looks finished — there is nothing open to do — so onboard_watch reported "no open work in the tracked stages" and the client silently had no onboarding at all. That is the failure this script exists to make impossible. python3 onboard_row.py # dry: what would be built python3 onboard_row.py --graft 86bbjx24j --live # template subtasks under an EXISTING row python3 onboard_row.py --client "Atlas Roofing" --live # create the row AND the subtasks Rules that matter: • The template tree is read LIVE, never hardcoded. Kirk edits it; the next client gets the edit. [[derive-dont-hardcode]] • Grafting is IDEMPOTENT by name at each level, so a half-finished run resumes instead of doubling. A crash in the middle of 200 creates must never leave 400. • Every create is retried with backoff, and a run that cannot finish exits NONZERO so run.sh pages Kirk rather than leaving a client half-onboarded. [[no-silent-failures]] [[resilient-runs]] cron 4,19,34,49 8-20 * * * /usr/bin/flock -n /tmp/onboard_row.lock /opt/ghl/run.sh onboardrow onboard_row.py --from-form --live >> /opt/ghl/logs/onboard_row.log 2>&1 # a Google Form response becomes a ClickUp client row with all 200 template subtasks and its 70 columns (Kirk 2026-08-23, replacing the MAKE scenario that built Atlas's row with the data and NONE of the subtasks). SEEDED first, so the nine old test responses on the sheet can never be built. Kirk 2026-08-23 asked for every 15 min. Minutes 4/19/34/49 are clear of every explicit single-minute job; overlap with the */2 planner-watch and */3 zoomwatch series is unavoidable and harmless, and the flock stops it ever overlapping itself. A run with nothing new is a no-op that costs one Sheets read. log /opt/ghl/logs/ · status /opt/ghl/status/onboardrow.last | every 15 min, 8am-8pm | 3 min ago | Tue 17:19 | OK |
build a pool of blank sub-accounts to get A2P certified in advance pooltopup · subaccount_pool.py Kirk 2026-08-25: "create 10 to start, without the numbers for now... I'm planning on creating blank ones that get A2P certified before time that I can plug new clients into in the future." What it does, in fullsubaccount_pool.py — build a pool of blank sub-accounts to get A2P certified in advance. Kirk 2026-08-25: "create 10 to start, without the numbers for now... I'm planning on creating blank ones that get A2P certified before time that I can plug new clients into in the future." A2P certification takes WEEKS, and it is the whole reason a defunct client's sub-account gets reused (Robertson -> Atlas, New Foundations -> Saber). Certifying blanks in advance turns that multi-week blocker into a same-day plug-in. ⛔ NO SNAPSHOT is pushed. These stay empty until a client claims one, because a snapshot applied today would be months stale by the time it is used — and the reuse flow pushes a current snapshot at assignment time anyway. Blank means blank. ⛔ NO NUMBER is bought. Kirk asked for it that way, and it keeps the monthly bill to the sub-accounts alone until the A2P process is worked out. --count is never assumed and the default is a dry run — not for cost (Kirk pays one flat fee for unlimited sub-accounts) but because manufacturing accounts nobody asked for is its own mess. python3 subaccount_pool.py --count 10 # show exactly what would be created python3 subaccount_pool.py --count 10 --live --yes python3 subaccount_pool.py --list # what the pool holds now cron 51 7 * * * /usr/bin/flock -n /tmp/pool_topup.lock /opt/ghl/run.sh pooltopup subaccount_pool.py --top-up --live --yes >> /opt/ghl/logs/subaccount_pool.log 2>&1 # keep 10 FREE NEW CLIENT POOL sub-accounts on hand (Kirk 2026-08-25). Creates at most 5 a run and refuses on a short agency read (junk accounts, not cost — Kirk pays one flat fee for unlimited). Minute 51 is odd (dodges */2 planner-watch) and hour 7 is outside the */3 8-20 band; clear of 2-59/5, 4-59/10, 6-56/10, 7-52/15, 8-58/30, 9/39, 10/35, 4/19/34/49, 1/23/41, 13/43 and 7/22/37/52. log /opt/ghl/logs/ · status /opt/ghl/status/pooltopup.last | daily at 7am:51 | 12 min ago | Wed 07:51 | OK |
call_tracker.py calls · call_tracker.py Logs calls from Fathom (growth calls) and Wavv/GHL (appt setting calls) to their respective tracking sheets. Sends post-call emotional check-in SMS to Kirk via GHL after each new call is detected. What it does, in fullcall_tracker.py Logs calls from Fathom (growth calls) and Wavv/GHL (appt setting calls) to their respective tracking sheets. Sends post-call emotional check-in SMS to Kirk via GHL after each new call is detected. Usage: python scripts/call_tracker.py --type growth # Fathom growth calls python scripts/call_tracker.py --type appt # Wavv appt setting calls python scripts/call_tracker.py --type both # both (default) python scripts/call_tracker.py --dry-run # preview without writing What it does: 1. Pulls new calls from Fathom API (growth) or GHL/Wavv (appt) 2. Uploads Wavv recordings to Dropbox /B2B Sales Calls/Appointment Setting/ 3. Adds new rows to the correct month's tab in the tracking sheet 4. Sends Kirk an SMS asking about his emotional state after the call 5. Skips calls already in the sheet (deduplication by date + contact name) Sheet columns (row 9 header, data from row 10): B: Row label (Call 1, Call 2, ...) C: Date (MM/DD/YY) D: Name E: Source F: Pricing / Pitch G: Call Length H: Recording Link I: Outcome J: My Emotions ← filled by Kirk's SMS reply (manual for now) K: Conclusion L: Notes cron 0 */2 * * * /opt/ghl/run.sh calls call_tracker.py --type growth >> /opt/ghl/logs/calls.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/calls.last | every 2h | 67 min ago | Tue 18:00 | OK |
call_tracker.py appt-calls · call_tracker.py Logs calls from Fathom (growth calls) and Wavv/GHL (appt setting calls) to their respective tracking sheets. Sends post-call emotional check-in SMS to Kirk via GHL after each new call is detected. What it does, in fullcall_tracker.py Logs calls from Fathom (growth calls) and Wavv/GHL (appt setting calls) to their respective tracking sheets. Sends post-call emotional check-in SMS to Kirk via GHL after each new call is detected. Usage: python scripts/call_tracker.py --type growth # Fathom growth calls python scripts/call_tracker.py --type appt # Wavv appt setting calls python scripts/call_tracker.py --type both # both (default) python scripts/call_tracker.py --dry-run # preview without writing What it does: 1. Pulls new calls from Fathom API (growth) or GHL/Wavv (appt) 2. Uploads Wavv recordings to Dropbox /B2B Sales Calls/Appointment Setting/ 3. Adds new rows to the correct month's tab in the tracking sheet 4. Sends Kirk an SMS asking about his emotional state after the call 5. Skips calls already in the sheet (deduplication by date + contact name) Sheet columns (row 9 header, data from row 10): B: Row label (Call 1, Call 2, ...) C: Date (MM/DD/YY) D: Name E: Source F: Pricing / Pitch G: Call Length H: Recording Link I: Outcome J: My Emotions ← filled by Kirk's SMS reply (manual for now) K: Conclusion L: Notes cron 20 5 * * * /opt/ghl/run.sh appt-calls call_tracker.py --type appt --csv /tmp/wavv_latest.csv >> /opt/ghl/logs/appt.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/appt-calls.last | daily at 5am:20 | 11h ago | Wed 05:20 | OK |
catch a junk property address BEFORE a crew drives to it addresswatch · address_watch.py Kirk 2026-08-14: a homeowner typed "will provide when you call" into the address field and the voice agent read it straight back as confirmation. The live fix is the agent calling check_property_address mid-call (guard.ridgerev.com/address) What it does, in fulladdress_watch.py — catch a junk property address BEFORE a crew drives to it. Kirk 2026-08-14: a homeowner typed "will provide when you call" into the address field and the voice agent read it straight back as confirmation. The live fix is the agent calling check_property_address mid-call (guard.ridgerev.com/address). But that fix has two single points of failure: the action has to be wired into the agent, and the agent has to actually invoke it. Neither is something this codebase controls. So this is the net underneath it. Whatever the agent did or did not do, every UPCOMING appointment gets its contact's address re-read straight from GHL and run through the same validator the live guard uses. A bad one texts Kirk while there is still time to phone the homeowner — the expensive failure is not a wrong string in a field, it is a crew burning a morning driving to nowhere. Deliberately read-only: it never rewrites an address. A wrong auto-correction would be worse than a flag, because it would look settled. See skills: no-silent-failures, resilient-runs. python3 address_watch.py # report only, no texts (safe to run any time) python3 address_watch.py --live # text Kirk about anything broken python3 address_watch.py --days 5 # look further ahead cron 11 7,15 * * * /opt/ghl/run.sh addresswatch address_watch.py --live >> /opt/ghl/logs/address_watch.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/addresswatch.last | 2x a day (7am-3pm) | 1h ago | Wed 07:11 | OK |
catch a prospect whose FIRST text from us was a stub, not the opener firstmsg · first_message_guard.py WHY Kirk 2026-08-13, Premier Hurricane Solutions: their first-ever message from us was ":) ^" — the fu-1 bump — sent 23 HOURS BEFORE the opener. They replied "Hi who's this?", which paused the sequence, so they never received the pitch at a What it does, in fullfirst_message_guard.py — catch a prospect whose FIRST text from us was a stub, not the opener. WHY Kirk 2026-08-13, Premier Hurricane Solutions: their first-ever message from us was ":) ^" — the fu-1 bump — sent 23 HOURS BEFORE the opener. They replied "Hi who's this?", which paused the sequence, so they never received the pitch at all. The lead was burned before it started. Roof Troops got the same bump at the same second, so it is a batch-level ordering fault. Cause is in GHL: the opener and the bump are separate workflows racing on independent timers, and nothing gates the bump on the opener having sent. GHL's Workflow API is GET-only, so that ordering can only be fixed in their UI — this cannot prevent it, only make it impossible to miss. A confused "who's this?" is otherwise the only signal, and only if the prospect bothers. Usage: python3 first_message_guard.py # report python3 first_message_guard.py --notify # ...and text Kirk about anyone new cron 40 10,15,20 * * * /usr/bin/flock -n /tmp/first_msg_guard.lock /opt/ghl/run.sh firstmsg first_message_guard.py --notify >> /opt/ghl/logs/responder.log 2>&1 # catch a prospect whose FIRST text was a bump not the opener (Kirk 2026-08-13: Premier Hurricane got ':) ^' 23h before the pitch, replied "who's this?", was never pitched). Detect only — the ordering fault is in a GHL workflow, which has no API. log /opt/ghl/logs/ · status /opt/ghl/status/firstmsg.last | 3x a day (10am-8pm) | 87 min ago | Tue 20:40 | OK |
cut the call summaries down to what the reader actually needs summaryrefine · summary_refine.py Kirk 2026-08-16: after an appointment is booked, refine the Homeowner Summary to only the essentials a roofer needs to know, before it reaches him an hour later. Same for the Transfer Summary, where applicable — different reader, so a diffe What it does, in fullsummary_refine.py — cut the call summaries down to what the reader actually needs. Kirk 2026-08-16: after an appointment is booked, refine the Homeowner Summary to only the essentials a roofer needs to know, before it reaches him an hour later. Same for the Transfer Summary, where applicable — different reader, so a different prompt: one is driving to a roof, the other is picking up a complaint. The agent writes whatever it gathered. That is the right thing for it to do — better too much than too little — but the person reading it is standing at a truck about to drive somewhere, and wants the roof, the access and the catch, not a call recap. This rewrites the field in place, on a schedule that lands well inside the hour. Two rules the rewrite must never break, both enforced in code rather than trusted to the model: - It may only use facts present in the input. No inferring a roof type, no guessing a cause. - If the result comes back empty, absurdly short, or longer than the original, the ORIGINAL is kept. A summary that reaches the roofer half-written is worse than one that is merely verbose. python3 summary_refine.py # show what it would write, change nothing python3 summary_refine.py --live # rewrite the field python3 summary_refine.py --days 3 # widen the lookback See skills: caching, no-silent-failures, supervised-release. cron 1,23,41 * * * * /opt/ghl/run.sh summaryrefine summary_refine.py --live >> /opt/ghl/logs/summary_refine.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/summaryrefine.last | 3x an hour, all day | 6 min ago | Tue 17:23 | OK |
delivery_sync.py delivery · delivery_sync.py Nightly Sendivo delivery sync so hard bounces never lag again (they lagged from Jul 3 -> Jul 8, and batch 4's undelivered/rejected were never removed until asked). What it does, in fulldelivery_sync.py Nightly Sendivo delivery sync so hard bounces never lag again (they lagged from Jul 3 -> Jul 8, and batch 4's undelivered/rejected were never removed until asked). Each run: 1. Pulls the last N days of Sendivo logs (default 4). 2. Saves the undelivered+REJECTED failures to the Delivery Reports folder so the tracking updater's SMS-Sent calc keeps subtracting BOTH (file-based method). 3. Extracts genuine HARD bounces (invalid destination/reference/network — dead numbers), enriches company/city/state from GHL, infers batch from the batch map, and appends any NEW ones to outputs/hard_bounces.csv (validate_master then permanently drops them from every future send). 4. Writes a self-verification the monitor reads. Soft failures (out of prepay credit, canceled) are logged but NOT hard-removed — those are valid numbers. Run: python3 scripts/delivery_sync.py [--days 4] [--dry-run] cron 0 1 * * * /opt/ghl/run.sh delivery delivery_sync.py --days 4 >> /opt/ghl/logs/delivery.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/delivery.last | daily at 1am:00 | 16h ago | Wed 01:00 | OK |
detect the Sendivo "stuck in pending / server backlog" lag Kirk hit sendivo-lag · sendivo_lag_check.py 2026-07-13 (GHL dispatched on time 9:30a-2:30p CST, but Sendivo held messages ~2h in pending, slowing replies). Runs DURING the send window; texts Kirk only if lag is detected (else quiet, unless --always). What it does, in fullsendivo_lag_check.py — detect the Sendivo "stuck in pending / server backlog" lag Kirk hit 2026-07-13 (GHL dispatched on time 9:30a-2:30p CST, but Sendivo held messages ~2h in pending, slowing replies). Runs DURING the send window; texts Kirk only if lag is detected (else quiet, unless --always). Signals (Sendivo API only exposes `created_at`, no live pending view as rich as the dashboard): • non-final messages (pending/queued/sending/accepted) still unresolved, and their age • throughput + freshness in the last hour (newest message age) A real backlog shows non-final messages aging past ~20 min. Reports raw numbers either way. Usage: python3 scripts/sendivo_lag_check.py # text Kirk only if lag python3 scripts/sendivo_lag_check.py --always # always text the summary cron 0 16 * * * /opt/ghl/run.sh sendivo-lag sendivo_lag_check.py >> /opt/ghl/logs/sendivo_lag.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/sendivo-lag.last | daily at 4pm:00 / daily at 6pm:30 | 67 min ago | Tue 18:30 | OK |
engaged_tracker.py engaged · engaged_tracker.py Automatically updates the Engaged Prospects Tracker sheet with new engaged contacts from GHL and fills in response timing (G-J) + follow-up dates (L-S). What it does, in fullengaged_tracker.py Automatically updates the Engaged Prospects Tracker sheet with new engaged contacts from GHL and fills in response timing (G-J) + follow-up dates (L-S). What it does: 1. Pulls all contacts with 'cold sms-engaged lead' tag from GHL 2. For each, gets their conversation to calculate: G: Did Kirk SMS them within 10 min of their first reply? H: Called (DD1) within 10 min? I: Called (DD2) within 2 hrs? J: Called (DD3) within 6 hrs? L-S: Dates of follow-up messages sent 3. Adds any NEW contacts (not already in the sheet) to the correct month tab 4. Updates existing rows with follow-up date progress Usage: python scripts/engaged_tracker.py # update June + July tabs python scripts/engaged_tracker.py --dry-run # preview without writing Sheet: https://docs.google.com/spreadsheets/d/1AebO6SFtX52V1Z-X9mxQCZ2wv1wRYlf0mBSvmqBXW10 cron 0 3 * * * /opt/ghl/run.sh engaged engaged_tracker.py >> /opt/ghl/logs/engaged.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/engaged.last | daily at 3am:00 | 13h ago | Wed 03:00 | OK |
engaged_tracker.py engaged-new · engaged_tracker.py Automatically updates the Engaged Prospects Tracker sheet with new engaged contacts from GHL and fills in response timing (G-J) + follow-up dates (L-S). What it does, in fullengaged_tracker.py Automatically updates the Engaged Prospects Tracker sheet with new engaged contacts from GHL and fills in response timing (G-J) + follow-up dates (L-S). What it does: 1. Pulls all contacts with 'cold sms-engaged lead' tag from GHL 2. For each, gets their conversation to calculate: G: Did Kirk SMS them within 10 min of their first reply? H: Called (DD1) within 10 min? I: Called (DD2) within 2 hrs? J: Called (DD3) within 6 hrs? L-S: Dates of follow-up messages sent 3. Adds any NEW contacts (not already in the sheet) to the correct month tab 4. Updates existing rows with follow-up date progress Usage: python scripts/engaged_tracker.py # update June + July tabs python scripts/engaged_tracker.py --dry-run # preview without writing Sheet: https://docs.google.com/spreadsheets/d/1AebO6SFtX52V1Z-X9mxQCZ2wv1wRYlf0mBSvmqBXW10 cron * 7-21 * * * flock -n /tmp/engaged_new.lock /opt/ghl/run.sh engaged-new engaged_tracker.py --new-only >> /opt/ghl/logs/engaged_new.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/engaged-new.last | every 1 min, 7am-9pm | just now | Tue 17:08 | OK |
evening auto-processor for the Wavv call history callhist · process_call_history.py Kirk drops the Wavv "Outbound Call History*.csv" into the shared Drive folder each morning; this pulls the newest file, runs dm_detector (fills the Engaged tracker's DM column) + connect_rate (fills the SMS tracker's Connect%/Close), and te What it does, in fullprocess_call_history.py — evening auto-processor for the Wavv call history. Kirk drops the Wavv "Outbound Call History*.csv" into the shared Drive folder each morning; this pulls the newest file, runs dm_detector (fills the Engaged tracker's DM column) + connect_rate (fills the SMS tracker's Connect%/Close), and texts Kirk a summary. Idempotent: skips if the newest file is the one already processed. This is the AUTONOMOUS path (cron); the manual path is running the two scripts by hand. The Drive folder is owned by + shared to the service account, so we read it with credentials.json (the OAuth token_drive.json only has drive.file scope = can't see user-uploaded files). [[deploy-and-sync]] Usage: python3 scripts/process_call_history.py # pull newest, process, text Kirk python3 scripts/process_call_history.py --no-notify # same, but don't SMS (supervised testing) python3 scripts/process_call_history.py --force # reprocess even if unchanged cron 0 20 * * 1-5 /opt/ghl/run.sh callhist process_call_history.py >> /opt/ghl/logs/callhist.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/callhist.last | Mon, Tue, Wed, Thu, Fri 8pm | 21h ago | Tue 20:00 | OK |
evening auto-processor for the Wavv call history callhist-night · process_call_history.py Kirk drops the Wavv "Outbound Call History*.csv" into the shared Drive folder each morning; this pulls the newest file, runs dm_detector (fills the Engaged tracker's DM column) + connect_rate (fills the SMS tracker's Connect%/Close), and te What it does, in fullprocess_call_history.py — evening auto-processor for the Wavv call history. Kirk drops the Wavv "Outbound Call History*.csv" into the shared Drive folder each morning; this pulls the newest file, runs dm_detector (fills the Engaged tracker's DM column) + connect_rate (fills the SMS tracker's Connect%/Close), and texts Kirk a summary. Idempotent: skips if the newest file is the one already processed. This is the AUTONOMOUS path (cron); the manual path is running the two scripts by hand. The Drive folder is owned by + shared to the service account, so we read it with credentials.json (the OAuth token_drive.json only has drive.file scope = can't see user-uploaded files). [[deploy-and-sync]] Usage: python3 scripts/process_call_history.py # pull newest, process, text Kirk python3 scripts/process_call_history.py --no-notify # same, but don't SMS (supervised testing) python3 scripts/process_call_history.py --force # reprocess even if unchanged cron 40 3 * * * /opt/ghl/run.sh callhist-night process_call_history.py --force >> /opt/ghl/logs/callhist.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/callhist-night.last | daily at 3am:40 | 13h ago | Wed 03:40 | OK |
fill the 4-week Cycle Tracking tab on every client tracker sheet cycletracker · cycle_tracker.py Kirk 2026-08-16: "each company tracking doc must have a section called cycle tracking ... cycle one is between this date and this date, and then you have all of it populated so each person knows how many total appointments was booked in thi What it does, in fullcycle_tracker.py — fill the 4-week Cycle Tracking tab on every client tracker sheet. Kirk 2026-08-16: "each company tracking doc must have a section called cycle tracking ... cycle one is between this date and this date, and then you have all of it populated so each person knows how many total appointments was booked in this time period." WHY THE SHEET HOLDS THE SETTINGS. There is no registry of which tracker belongs to which GHL sub-account, and inventing one means a hand-maintained list that rots the first time somebody forgets it. Instead the SHEET opts itself in: a Cycle Tracking tab with two cells filled — the sub-account id and the day ads started — is all it takes to be picked up. Nothing to register, nothing to deploy, and whoever sets up the sheet owns it. See skill: derive-dont-hardcode. python3 cycle_tracker.py # show what it would write, change nothing python3 cycle_tracker.py --live python3 cycle_tracker.py --live --sheet <id> # one sheet only See skills: derive-dont-hardcode, no-silent-failures, timezone-day-attribution, batched-sheets-writes. cron 20 23 * * * /opt/ghl/run.sh cycletracker cycle_tracker.py --live >> /opt/ghl/logs/cycle_tracker.log 2>&1 # 4-week cycle table on every client tracker sheet (Kirk 2026-08-16). Daily is plenty: a cycle is 28 days. 06:40 sits after the 06:00 backup and clear of the 07:00 block. log /opt/ghl/logs/ · status /opt/ghl/status/cycletracker.last | daily at 11pm:20 | 17h ago | Tue 23:20 | OK |
find cities we PAID Apify to scrape that were never turned into leads stranded · stranded_cities.py Kirk 2026-08-15, and he called this the important one. On 2026-08-15 a poll blip killed a 40-city run; 19 cities had already downloaded. Checking found 27 city folders holding a dataset_*_original.xlsx and NO roofing_contractors_*_final.xls What it does, in fullstranded_cities.py — find cities we PAID Apify to scrape that were never turned into leads. Kirk 2026-08-15, and he called this the important one. On 2026-08-15 a poll blip killed a 40-city run; 19 cities had already downloaded. Checking found 27 city folders holding a dataset_*_original.xlsx and NO roofing_contractors_*_final.xlsx — some predating that failure, so it had happened before and nobody noticed. Running the pipeline over them produced 920 leads and three batches for ZERO extra Apify spend. That is the shape of the problem: the money is spent at DOWNLOAD time, the value only appears after PROCESSING, and nothing connected the two. A stranded city is invisible — no error, no alert, no missing-file warning — it just quietly never becomes a lead. Usage: python3 stranded_cities.py # report python3 stranded_cities.py --notify # ...and text Kirk if any are stranded cron 55 9 * * * /usr/bin/flock -n /tmp/stranded.lock /opt/ghl/run.sh stranded stranded_cities.py --notify >> /opt/ghl/logs/auto_scrape.log 2>&1 # cities we PAID to scrape but never processed (Kirk 2026-08-15: 27 were sitting idle, worth 920 leads, some predating the failure that exposed them). Runs after the 07:00 scrape so it only flags genuinely stuck work. log /opt/ghl/logs/ · status /opt/ghl/status/stranded.last | daily at 9am:55 | 7h ago | Wed 09:55 | OK |
GHL-NATIVE backfill of outbound_caller_label + outbound_area_code lp-selfheal · backfill_local_presence_ghl.py Supersedes backfill_local_presence.py (which read the SMS master CSV + upserted by phone, so it only ever covered leads present in ONE file — leaving 464 GHL leads blank). This one works off the LIVE contacts: paginate every contact in the What it does, in fullbackfill_local_presence_ghl.py — GHL-NATIVE backfill of outbound_caller_label + outbound_area_code. Supersedes backfill_local_presence.py (which read the SMS master CSV + upserted by phone, so it only ever covered leads present in ONE file — leaving 464 GHL leads blank). This one works off the LIVE contacts: paginate every contact in the cold-SMS sub-account, and for any with a BLANK caller label but a coverable state, set both fields by contact ID. Idempotent (skips already-set + states with no WAVV number + TX). Safe to run repeatedly / on a cron — it self-heals any lead the upsert path missed. [[resilient-runs]] [[project-scientific-testing]] Usage: python3 scripts/backfill_local_presence_ghl.py --dry-run # count what needs setting, write nothing python3 scripts/backfill_local_presence_ghl.py --limit 5 # supervised small run python3 scripts/backfill_local_presence_ghl.py # fix everything blank-but-coverable cron 0 23 * * * /opt/ghl/run.sh lp-selfheal backfill_local_presence_ghl.py >> /opt/ghl/logs/lp_backfill.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/lp-selfheal.last | daily at 11pm:00 | 18h ago | Tue 23:00 | OK |
Growth-Call Objection + Q&A Tracker (the sales-side analog of the reply… objections · objection_tracker.py Reads Kirk's Fathom GROWTH CALL transcripts and, per call, uses claude-opus-4-8 to detect: • which OBJECTIONS the prospect raised (mapped to Kirk's own taxonomy / buckets: Smokescreen · Blaming Circumstances · Blaming Other People · Blaming What it does, in fullGrowth-Call Objection + Q&A Tracker (the sales-side analog of the reply vault) Reads Kirk's Fathom GROWTH CALL transcripts and, per call, uses claude-opus-4-8 to detect: • which OBJECTIONS the prospect raised (mapped to Kirk's own taxonomy / buckets: Smokescreen · Blaming Circumstances · Blaming Other People · Blaming Self), and whether Kirk's handling PROGRESSED the sale on that specific point, and • which pre-price QUESTIONS the prospect asked, whether Kirk answered them in a way that moved the sale forward, and the actual answer copy he used (for A/B). It then aggregates, per objection / per question: Times Faced · Times Handled Well · Success Rate · Weighted-Recent Rate · Last Seen "Weighted-Recent Rate" applies exponential decay (30-day half-life) so old results don't drag down current form — recent calls dominate the headline number. HARD RULE — never touches Kirk's authored content. Kirk's objections, buckets, questions, answers and rebuttals are READ-ONLY inputs. This script ONLY writes to its own dedicated tabs (OBJ_TAB / QA_TAB / *_LOG_TAB). It never writes to the vaults' authored tabs ('Q&A', 'Closes used', 'references:'). Usage: python scripts/objection_tracker.py # incremental (last 30 days) python scripts/objection_tracker.py --days 180 # widen the fetch window python scripts/objection_tracker.py --rebuild # clear cache + re-analyze all python scripts/objection_tracker.py --dry-run # analyze + print, don't write cron 5 4 * * * ( /opt/ghl/run.sh objections objection_tracker.py --days 45 && /opt/ghl/run.sh showrate show_tracker.py --days 45 && /opt/ghl/run.sh ltv ltv_tracker.py && /opt/ghl/run.sh batchfunnel batch_funnel.py ) >> /opt/ghl/logs/growthtrack.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/objections.last | daily at 4am:05 | 13h ago | Wed 04:05 | OK |
homeowner replies "yes" to the appointment confirmation → move the apptconfirm · appt_confirm_watch.py opportunity from Booked-Unconfirmed to Booked-CONFIRMED. What it does, in fullappt_confirm_watch.py — homeowner replies "yes" to the appointment confirmation → move the opportunity from Booked-Unconfirmed to Booked-CONFIRMED. Kirk 2026-08-15 (service delivery, B2C): "once they reply 'yes' to my appointment confirmation that they'll be there... update pipeline stage to appointment booked confirmed stage". WHY THIS IS SAFE TO RUN UNATTENDED It only ever moves an opportunity that is CURRENTLY sitting in a Booked-Unconfirmed stage. A stray "yes" from someone in New Lead, Proposal Sent or Won does nothing at all — the reply alone is never enough. That guard is what stops a chatty homeowner dragging their own deal backwards or forwards through the pipeline. Stage ids are resolved BY NAME per sub-account. Every client's pipeline is a snapshot copy with DIFFERENT ids, so a hardcoded id would silently target the wrong stage — or nothing — the moment it ran for a second client. See skill: derive-dont-hardcode. Usage: python3 appt_confirm_watch.py # dry run across every client we hold a token for python3 appt_confirm_watch.py --live python3 appt_confirm_watch.py --seed # mark current replies as seen; no moves cron 4-49/15 7-21 * * * /usr/bin/flock -n /tmp/appt_confirm.lock /opt/ghl/run.sh apptconfirm appt_confirm_watch.py --live >> /opt/ghl/logs/appt_confirm.log 2>&1 # homeowner replies "yes" to the appointment confirmation -> move the opportunity Booked-Unconfirmed to Booked-CONFIRMED (Kirk 2026-08-15, B2C service delivery). Only ever moves an opp already sitting in an unconfirmed stage; stage ids resolved BY NAME per client since every sub-account's copy differs. log /opt/ghl/logs/ · status /opt/ghl/status/apptconfirm.last | every 15 min, 7am-9pm | 3 min ago | Tue 17:19 | OK |
INSTANT reply triage for the RidgeRev SMS sub (Kirk 2026-07-19: responder · reply_responder.py "immediately, immediately, immediately — tag + first message handled for me, I just call"). What it does, in fullreply_responder.py — INSTANT reply triage for the RidgeRev SMS sub (Kirk 2026-07-19: "immediately, immediately, immediately — tag + first message handled for me, I just call"). Every minute (cron * 7-21, flock): 1. New inbound SMS from a cold-SMS prospect → classify (tracking_updater cache). 2. Positive OR ambiguous → tag `cold sms-engaged lead` RIGHT AWAY (Kirk's call boards light up; engaged_tracker's every-minute pass adds the sheet row). 3. Auto-send the right first reply, at their 20-SECOND mark exactly (polls every 5s): • "call me / sure / send them" (no question to answer) → the NEW-LEAD text: wrapping-up-another-call + the contact's assigned outbound number + talk soon — and Kirk gets a 🔥 CALL NOW text with the 60-second deadline. • a question/objection → the matching SMS Reply Vault answer (V1 = first occurrence in the vault sheet — read FRESH each run, so Kirk's edits apply). • no confident match → tag only + alert Kirk to answer manually. NEVER guess. 4. Every auto-send is echoed to Kirk. Nothing is ever silent. SAFETY RAILS (feedback_send_safety): • Kill switch: `touch /opt/ghl/PAUSE_RESPONDER` stops all sends instantly. • Skip if Kirk (or anyone) already replied after the inbound — never double-reply. • Skip DND contacts, bare STOP texts, and non-cold-SMS contacts. • ONE auto-send per contact EVER — the initial message only (tagging still happens on later replies; after that they're either booked or cold, Kirk handles it). • Templates with unfilled {{placeholders}} are never sent. State: outputs/reply_responder_state.json (seeded at deploy — no backlog blasting). cron * 7-21 * * * flock -n /tmp/reply_responder.lock /opt/ghl/run.sh responder reply_responder.py >> /opt/ghl/logs/responder.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/responder.last | every 1 min, 7am-9pm | 2 min ago | Tue 17:08 | OK |
INSTANT reply triage for the RidgeRev SMS sub (Kirk 2026-07-19: holdingtext · reply_responder.py "immediately, immediately, immediately — tag + first message handled for me, I just call"). What it does, in fullreply_responder.py — INSTANT reply triage for the RidgeRev SMS sub (Kirk 2026-07-19: "immediately, immediately, immediately — tag + first message handled for me, I just call"). Every minute (cron * 7-21, flock): 1. New inbound SMS from a cold-SMS prospect → classify (tracking_updater cache). 2. Positive OR ambiguous → tag `cold sms-engaged lead` RIGHT AWAY (Kirk's call boards light up; engaged_tracker's every-minute pass adds the sheet row). 3. Auto-send the right first reply, at their 20-SECOND mark exactly (polls every 5s): • "call me / sure / send them" (no question to answer) → the NEW-LEAD text: wrapping-up-another-call + the contact's assigned outbound number + talk soon — and Kirk gets a 🔥 CALL NOW text with the 60-second deadline. • a question/objection → the matching SMS Reply Vault answer (V1 = first occurrence in the vault sheet — read FRESH each run, so Kirk's edits apply). • no confident match → tag only + alert Kirk to answer manually. NEVER guess. 4. Every auto-send is echoed to Kirk. Nothing is ever silent. SAFETY RAILS (feedback_send_safety): • Kill switch: `touch /opt/ghl/PAUSE_RESPONDER` stops all sends instantly. • Skip if Kirk (or anyone) already replied after the inbound — never double-reply. • Skip DND contacts, bare STOP texts, and non-cold-SMS contacts. • ONE auto-send per contact EVER — the initial message only (tagging still happens on later replies; after that they're either booked or cold, Kirk handles it). • Templates with unfilled {{placeholders}} are never sent. State: outputs/reply_responder_state.json (seeded at deploy — no backlog blasting). cron * 22-23,0-6 * * * flock -n /tmp/reply_responder.lock /opt/ghl/run.sh holdingtext reply_responder.py --offhours >> /opt/ghl/logs/holdingtext.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/holdingtext.last | every 1 min, 12am-11pm | 10h ago | Tue 22:00 | OK |
keep each client's "Twilio Number" custom value on the number their twilionum · twilio_number_sync.py messages actually come from. What it does, in fulltwilio_number_sync.py — keep each client's "Twilio Number" custom value on the number their messages actually come from. Kirk 2026-08-17: the lead texts tell a homeowner which number to expect, and that is the whole reason they lift pickup — an expected number gets answered, an unknown one does not. So the value behind that merge field has to be right for every client, forever, without anyone maintaining it. It was 'None' on both clients until today. WHERE THE NUMBER COMES FROM. Not a config field, and not the GHL number endpoints — those return empty pools for these sub-accounts. It is read from the FROM address on the client's own outbound SMS. That is the strongest source available: not what somebody typed into a setting, but the number homeowners have actually been receiving messages from. A setting can be stale; observed behaviour cannot. python3 twilio_number_sync.py # show what it would set, change nothing python3 twilio_number_sync.py --live See skills: derive-dont-hardcode, no-silent-failures, verify-before-concluding. cron 30 12 * * * /opt/ghl/run.sh twilionum twilio_number_sync.py --live >> /opt/ghl/logs/twilio_number.log 2>&1 # keep each client Twilio Number custom value on the number their own outbound SMS come from, which is what the lead texts tell a homeowner to expect (Kirk 2026-08-17). Daily is ample. 12:30 is empty either side. log /opt/ghl/logs/ · status /opt/ghl/status/twilionum.last | daily at 12pm:30 | 4h ago | Wed 12:30 | OK |
Kickoff-form auto-tagger (replaces the brittle Zapier zap whose GHL OAuth… kickoff · form_kickoff_tag.py When a new response lands in the RidgeRev Kickoff Form's linked response Sheet, find that prospect in the RidgeRev MAIN GHL sub-account (by email, then phone) and add the tag 'kickoff form submitted' — which fires the GHL "thanks for fillin What it does, in fullform_kickoff_tag.py — Kickoff-form auto-tagger (replaces the brittle Zapier zap whose GHL OAuth expired). When a new response lands in the RidgeRev Kickoff Form's linked response Sheet, find that prospect in the RidgeRev MAIN GHL sub-account (by email, then phone) and add the tag 'kickoff form submitted' — which fires the GHL "thanks for filling out the form" automation. SAFE + idempotent (Kirk: "cache it, no re-work"): • Dedups every processed response in status/kickoff_tagged.json (each response handled once). • Also SKIPS a contact that already has the tag, so the GHL "thanks" automation can NEVER double-fire. • FIRST RUN (or --seed) baselines the current responses WITHOUT tagging — so enabling it never blasts a "thanks" message at old clients. Only NEW responses from then on get tagged. (--backfill tags untagged ones in a recent window if Kirk wants the Zap-downtime gap filled — accepts a slightly-late "thanks".) ONE-TIME SETUP (Google Form UI, Kirk): 1. Responses tab -> "Link to Sheets" -> create the response spreadsheet. 2. Share that spreadsheet with main-service-account-1@gentle-complex-472823-f8.iam.gserviceaccount.com (Viewer). 3. Put its Sheet ID in /opt/ghl/.env as KICKOFF_SHEET_ID=<id> Cron: every 10 min. Alerts Kirk on failure (no-silent-failures). cron 4-59/10 7-22 * * * /usr/bin/flock -n /tmp/kickoff.lock /opt/ghl/run.sh kickoff form_kickoff_tag.py >> /opt/ghl/logs/kickoff.log 2>&1 # tag kickoff-form submitters (RidgeRev main) log /opt/ghl/logs/ · status /opt/ghl/status/kickoff.last | every 10 min, 7am-10pm | 3 min ago | Tue 17:14 | OK |
Kirk edits a Show Log tag → everything refreshes itself tagwatch · kirk_tag_watch.py Kirk (2026-07-19): "I don't want to keep manually doing this every time" — after he typed a Kirk-tag override (e.g. Jason → Bad-fit), someone had to re-run the show tracker for it to count. Now this watcher does it. What it does, in fullkirk_tag_watch.py — Kirk edits a Show Log tag → everything refreshes itself. Kirk (2026-07-19): "I don't want to keep manually doing this every time" — after he typed a Kirk-tag override (e.g. Jason → Bad-fit), someone had to re-run the show tracker for it to count. Now this watcher does it. Every cycle (hourly cron, daytime): 1. ONE cheap Sheets read: Show Log columns A/B/H (date, prospect, Kirk tag). 2. Compare rows-with-a-tag against the last snapshot (outputs/kirk_tag_snapshot.json). 3. No change → exit silently (zero API/LLM cost — the caching rule). Change → re-run show_tracker.py then constraint_engine.py (no text blast), and only save the new snapshot AFTER both succeed, so a failed refresh retries next cycle instead of getting lost. First run just initializes the snapshot (no refresh storm). cron 37 7-22 * * * /opt/ghl/run.sh tagwatch kirk_tag_watch.py >> /opt/ghl/logs/tagwatch.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/tagwatch.last | 16x a day (7am-10pm) | 30 min ago | Tue 17:37 | OK |
Kirk's ONE testing system ($100M Leads pp.236-243, built 2026-07-18) warmap · constraint_engine.py Kirk WRITES in one place: 'Test Ledger' tab (Metric Tracker sheet) — one row per test. Kirk LOOKS in one place: 'War Map' tab (same sheet) + a Monday-morning text. The engine does the rest: 1. FUNNEL — computes every step's conversion + sam What it does, in fullconstraint_engine.py — Kirk's ONE testing system ($100M Leads pp.236-243, built 2026-07-18). Kirk WRITES in one place: 'Test Ledger' tab (Metric Tracker sheet) — one row per test. Kirk LOOKS in one place: 'War Map' tab (same sheet) + a Monday-morning text. The engine does the rest: 1. FUNNEL — computes every step's conversion + sample size from the live sources (batch rows, show_tracker tagging, LTV sheet). 2. CONSTRAINT — ranks steps by gap-to-target, ignoring steps without enough n (Hormozi: biggest drop-off wins; our guard: only where the data can be trusted). 3. TESTS — reads the Ledger, auto-fills Status/Result per row (n so far, test vs baseline, verdict). step=show rows are MIRRORED into the Growth sheet's 'Show Tests' tab so show_tracker stamps every booked call (Kirk never edits Show Tests directly anymore — the Ledger is the single entry point). 4. YOUR MOVE — one prescriptive line: start a test / keep running / declare winner / 4-strikes → next constraint. Texts Kirk on Mondays (and with --notify). Cadence: cron Mondays 07:15 CST + run anytime by hand. Writes ONLY the 'War Map' + 'Test Ledger' auto-columns (+ the Show Tests mirror). Zero LLM calls. Ledger columns (Kirk fills A-F,I; engine fills G-H): A Test# | B Step (prr/speed/connect/show/offer/close) | C What changed (ONE thing) | D Where (batch #s like "12,13" · booked · held · all) | E Start YYYY-MM-DD | F End (blank=running) | G Status AUTO | H Result AUTO | I Notes cron 15 7 * * 1 /opt/ghl/run.sh warmap constraint_engine.py --notify >> /opt/ghl/logs/warmap.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/warmap.last | Mon 7am | 33h ago | Mon 07:15 | OK |
Kirk's personal weekly calendar planner planner-week · week_planner.py Kills the 2.5-hour Sunday calendar block-out. Reads your REAL calendars (never edits them): • personal Google Calendar (webb.delroy33@gmail.com) — real meetings + habit noise • GHL RidgeRev growth demos — the sales demos (booked by prospect What it does, in fullweek_planner.py — Kirk's personal weekly calendar planner. Kills the 2.5-hour Sunday calendar block-out. Reads your REAL calendars (never edits them): • personal Google Calendar (webb.delroy33@gmail.com) — real meetings + habit noise • GHL RidgeRev growth demos — the sales demos (booked by prospects) • War Map sheet — the operational task list Then Opus builds a slot-first day (5 call slots reserved first, fixed meetings locked, gym, deep work) following the Constitution + your morning/evening skeleton. WRITE-ONLY to the dedicated "Plan" calendar (a rebuildable overlay). Your real calendars are the untouchable source of truth. Rebuilding the Plan overlay can never delete a real event. Usage: python Planning/week_planner.py --days 7 # generate + print (propose only) python Planning/week_planner.py --days 7 --write # also write blocks to the Plan calendar python Planning/week_planner.py --today --write # just rebuild TODAY (from now forward) cron 30 18 * * 0 /opt/ghl/run.sh planner-week week_planner.py --days 7 --write >> /opt/ghl/logs/planner.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/planner-week.last | Sun 6pm | 46h ago | Sun 18:30 | OK |
Kirk's personal weekly calendar planner planner-watch · week_planner.py Kills the 2.5-hour Sunday calendar block-out. Reads your REAL calendars (never edits them): • personal Google Calendar (webb.delroy33@gmail.com) — real meetings + habit noise • GHL RidgeRev growth demos — the sales demos (booked by prospect What it does, in fullweek_planner.py — Kirk's personal weekly calendar planner. Kills the 2.5-hour Sunday calendar block-out. Reads your REAL calendars (never edits them): • personal Google Calendar (webb.delroy33@gmail.com) — real meetings + habit noise • GHL RidgeRev growth demos — the sales demos (booked by prospects) • War Map sheet — the operational task list Then Opus builds a slot-first day (5 call slots reserved first, fixed meetings locked, gym, deep work) following the Constitution + your morning/evening skeleton. WRITE-ONLY to the dedicated "Plan" calendar (a rebuildable overlay). Your real calendars are the untouchable source of truth. Rebuilding the Plan overlay can never delete a real event. Usage: python Planning/week_planner.py --days 7 # generate + print (propose only) python Planning/week_planner.py --days 7 --write # also write blocks to the Plan calendar python Planning/week_planner.py --today --write # just rebuild TODAY (from now forward) cron */2 5-21 * * * /opt/ghl/run.sh planner-watch week_planner.py --watch >> /opt/ghl/logs/planner.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/planner-watch.last | every 2 min, 5am-9pm | just now | Tue 17:08 | OK |
monitor.py monitor · monitor.py Nightly health digest for the RidgeRev automation server. Reads the status each routine wrote (via run.sh) and texts Kirk ONE summary: everything that ran + anything that failed or went stale. Absence of this text = server down. What it does, in fullmonitor.py Nightly health digest for the RidgeRev automation server. Reads the status each routine wrote (via run.sh) and texts Kirk ONE summary: everything that ran + anything that failed or went stale. Absence of this text = server down. A routine is a problem if: it never ran, its last run FAILED, or its last run is older than its expected cadence (didn't fire). Run: python3 scripts/monitor.py (cron: after the nightly routines) cron 30 4 * * * /opt/ghl/run.sh monitor monitor.py >> /opt/ghl/logs/monitor.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/monitor.last | daily at 4am:30 | 12h ago | Wed 04:30 | OK |
monthly estimate of what the RidgeRev automation stack costs to run cost-digest · cost_digest.py Texts Kirk a breakdown on the 1st. Real where measurable (Sendivo SMS spend from the API), estimated (from the 2026-07-14 cost audit) for the stable compute pieces. [[project-automation-status]] What it does, in fullcost_digest.py — monthly estimate of what the RidgeRev automation stack costs to run. Texts Kirk a breakdown on the 1st. Real where measurable (Sendivo SMS spend from the API), estimated (from the 2026-07-14 cost audit) for the stable compute pieces. [[project-automation-status]] Usage: python3 scripts/cost_digest.py # compute + text Kirk python3 scripts/cost_digest.py --dry-run # print only cron 0 13 1 * * /opt/ghl/run.sh cost-digest cost_digest.py >> /opt/ghl/logs/cost.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/cost-digest.last | day 1 of the month, 1pm / day 15 of the month, 1pm | 10d ago | Tue 13:00 | OK |
monthly_setup.py monthly · monthly_setup.py Runs on the 25th of each month. Creates next month's tabs in both tracking sheets so everything is ready before the new month starts. What it does, in fullmonthly_setup.py Runs on the 25th of each month. Creates next month's tabs in both tracking sheets so everything is ready before the new month starts. What it creates: SMS Tracking sheet: - "{Month}" tab (copy of "Month (Make a copy)" template) - "{Month} Mappings" tab (copy of most recent Mappings tab, cleared + pre-filled) Engaged Prospects Tracker: - "{Month}" tab (copy of "TEMPLATE - MONTH (Make a copy)") The Mappings tab is pre-populated with all weekdays of next month, ONE ROW PER WEEKDAY = one batch (daily send cadence — Kirk 2026-07-28 doubled the send rate to 2 SMS/min, so a ~580 batch drains in ~1 day; was 2-day pairs at 1/min). Batch numbers are pre-numbered continuing from last month; Kirk adjusts dates/batches to match his actual send schedule. Usage: python scripts/monthly_setup.py # creates next month's tabs python scripts/monthly_setup.py --month aug # force a specific month python scripts/monthly_setup.py --dry-run # preview only cron 0 7 25 * * /opt/ghl/run.sh monthly monthly_setup.py >> /opt/ghl/logs/monthly.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/monthly.last | day 25 of the month, 7am | 10h ago | Fri 07:00 | OK |
nightly off-server backup of everything that is NOT already in git backup · backup_to_dropbox.py Tars /opt/ghl/{outputs, memory, skills} (live data + Claude memory + skills) and uploads to Dropbox /RidgeRev-Backups/. Scripts themselves are in git (recoverable) so they're skipped; this covers the master CSVs, hard_bounces, and the memor What it does, in fullbackup_to_dropbox.py — nightly off-server backup of everything that is NOT already in git. Tars /opt/ghl/{outputs, memory, skills} (live data + Claude memory + skills) and uploads to Dropbox /RidgeRev-Backups/. Scripts themselves are in git (recoverable) so they're skipped; this covers the master CSVs, hard_bounces, and the memory/skills that aren't version-controlled. Keeps the last 14 daily archives (older ones deleted). DROPBOX_TOKEN in .env. Best-effort + loud: prints OK/FAIL for the monitor + status file. Usage: python3 scripts/backup_to_dropbox.py cron 0 6 * * * /opt/ghl/run.sh backup backup_to_dropbox.py >> /opt/ghl/logs/backup.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/backup.last | daily at 6am:00 | 11h ago | Wed 06:00 | OK |
once a week, check every automation in the system is actually working audit · automation_audit.py Kirk 2026-08-24: "create a full automations audit once weekly across all automations in the entire system. Very thoroughly and cronned." What it does, in fullautomation_audit.py — once a week, check every automation in the system is actually working. Kirk 2026-08-24: "create a full automations audit once weekly across all automations in the entire system. Very thoroughly and cronned." Written the day after a single client's onboarding surfaced eleven separate failures, every one of which had been invisible for weeks. The shape was always the same: something could not do its job, said OK, and nobody found out until a human went looking. So this looks for that shape everywhere at once. Each check below is one that would have caught a real failure: 1 DEPLOY PARITY create_subaccount.py sat committed-but-never-deployed and blocked a client. Checks BOTH directions — the server being ahead is real too, and pushing the repo copy over it would delete live work. 2 CRON -> SCRIPT a crontab line pointing at a file that is not there fails at 3am, silently. 3 CREDENTIALS STRIPE_API_KEY vanished; stripe_ltv ran 14 nights doing nothing, all OK. 4 STALENESS a job that stopped running looks identical to one with nothing to do. 5 SILENT SUCCESS the killer. An "OK" whose own message says it did nothing. 6 EXIT INTEGRITY static scan for `if not KEY: print(...); return` — success on failure. 7 DEGRADED WRITES a partial fetch that still writes zeros over real data. 8 LOG ERRORS tracebacks and 401/5xx in the last week that nobody was told about. 9 CRON COLLISIONS two heavy jobs on the same minute contend and one loses. python3 automation_audit.py # print the report python3 automation_audit.py --live # ...and text Kirk the summary cron 13 9 * * 0 /opt/ghl/run.sh audit automation_audit.py --live >> /opt/ghl/logs/automation_audit.log 2>&1 # weekly full-system automations audit (Kirk 2026-08-24). Sun 9:13a: odd minute dodges */2 planner-watch, not /3 (zoomwatch), not 2-59/5 (post_booking), not 10,35 (cancel_watch), not 4,19,34,49 (onboard_row), not :20 (onboard_pipeline). log /opt/ghl/logs/ · status /opt/ghl/status/audit.last | Sun 9am | 32h ago | Sun 09:13 | OK |
one active appointment per homeowner, and it is the latest one apptdedupe · appt_dedupe.py Kirk 2026-08-16: "if an appointment is rescheduled, cancel all previous appointments. There should only be one active appointment at any one time and it's gonna be the last one." What it does, in fullappt_dedupe.py — one active appointment per homeowner, and it is the latest one. Kirk 2026-08-16: "if an appointment is rescheduled, cancel all previous appointments. There should only be one active appointment at any one time and it's gonna be the last one." A reschedule in GHL does not always retire the original. The homeowner ends up holding two live slots, the client's calendar shows two, and a crew can be dispatched to a slot the homeowner has already moved on from. This keeps the most RECENTLY BOOKED appointment and cancels the rest. Scope is the HOMEOWNER, not the calendar. Appointments are grouped by contact across EVERY calendar in the sub-account, because the common reschedule leaves the original on the main inspection calendar and the new one on the reschedule calendar. Deduping per calendar would see one appointment in each and leave both live. Verified 2026-08-16 with a deliberate cross-calendar pair: the original on the main calendar was cancelled, the reschedule on the reschedule calendar was kept. Most recently BOOKED, not the earliest in the day: a reschedule is created after the original, but the new slot can easily be sooner. dateAdded is the only field that says which one the homeowner actually chose last. Cancels via PUT /calendars/events/appointments/{id} {"appointmentStatus":"cancelled"} — proven on a throwaway appointment 2026-08-16: HTTP 200, status becomes cancelled, deleted stays False. Do NOT use PUT /calendars/events/{id}; that path answers {"succeeded":true} and sets deleted=True, which removes the appointment from the calendar instead of cancelling it. python3 appt_dedupe.py # report only, cancels nothing python3 appt_dedupe.py --live # actually cancel the superseded ones python3 appt_dedupe.py --days 120 # look further ahead See skills: no-silent-failures, supervised-release. cron 13,43 * * * * /opt/ghl/run.sh apptdedupe appt_dedupe.py --live >> /opt/ghl/logs/appt_dedupe.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/apptdedupe.last | every 30 min, all day | 24 min ago | Tue 17:13 | OK |
one page that says what every automation is and whether it is working board · automation_board.py Kirk 2026-08-25: "I need a full document that has every single automation that is on the server, what a description of it is, all of the tasks that it does, the times that it runs, and the status of it, live updated regularly... only page m What it does, in fullautomation_board.py — one page that says what every automation is and whether it is working. Kirk 2026-08-25: "I need a full document that has every single automation that is on the server, what a description of it is, all of the tasks that it does, the times that it runs, and the status of it, live updated regularly... only page me if something is wrong. You don't have to tell me okay-okay-okay every day." So this is the base he checks, and the alerts go quiet unless something is actually broken. EVERYTHING HERE IS DERIVED. Nothing is typed twice: - the job list comes from `crontab -l`, so a cron added tomorrow appears with no edit here - the description comes from each script's own module docstring - the schedule is rendered from the cron spec in English - status/last-run come from /opt/ghl/status/<label>.last, which run.sh writes for every job - the next run is computed from the spec, not guessed A board that has to be hand-maintained is a board that is wrong by the second week. [[derive-dont-hardcode]] python3 automation_board.py # write the page locally python3 automation_board.py --deploy # ...and publish it to the server cron 7,22,37,52 * * * * /usr/bin/flock -n /tmp/automation_board.lock /opt/ghl/run.sh board automation_board.py >> /opt/ghl/logs/automation_board.log 2>&1 # the Automation Board at ops.ridgerev.com, rebuilt every 15 min (Kirk 2026-08-25). Minutes dodge */2 planner-watch, /3 zoomwatch, 2-59/5 post_booking, 10,35 cancel_watch, 4,19,34,49 onboard_row, :20 pipeline. log /opt/ghl/logs/ · status /opt/ghl/status/board.last | every 15 min, all day | 15 min ago | Tue 17:22 | OK |
ONE sheet that walks every Facebook Business Manager through its warm-up bmwarmup · bm_warmup.py Kirk 2026-08-22: "I need this all in one — when I get new BMs in the future I don't need multiple sheets for each, just one location to do everything." What it does, in fullbm_warmup.py — ONE sheet that walks every Facebook Business Manager through its warm-up. Kirk 2026-08-22: "I need this all in one — when I get new BMs in the future I don't need multiple sheets for each, just one location to do everything." So there is a single spreadsheet with two tabs and no command to run: BMs — Kirk adds a ROW per Business Manager: name, how many ad accounts it holds, how many already exist. That row is the only thing he ever creates. Warm-Up — every step for every BM, generated from that row. He ticks Status -> Done as he goes. Adding a BM is adding a row. The next hourly pass builds its steps and starts texting him the one next safe action, prefixed with which BM it belongs to. ⛔ THE CONTRADICTION RULE: Pro Ads' written guide, their Dec 2024 video and their Aug 2026 message disagree on the two things most likely to get a BM banned. Wherever they conflict the LONGEST wait wins — admins day 7 (not day 1 as the video says), freeze 4 days, first ad day 8. Never a minimum. ⛔ EVERY CLOCK STARTS AT THE PREVIOUS TICK, not a calendar date. "24h after adding the payment method" means 24h after HE added it. Anchoring to dates lets a late step compress the next gap to nothing, which is the rushed setup Meta bans for. Usage: bm_warmup.py --create-hub # once: build the one spreadsheet bm_warmup.py # dry run bm_warmup.py --live # normal (cron) cron 28 8-21 * * * /usr/bin/flock -n /tmp/bm_warmup.lock /opt/ghl/run.sh bmwarmup bm_warmup.py --live >> /opt/ghl/logs/bm_warmup.log 2>&1 # Facebook BM warm-up walkthrough (Kirk 2026-08-21): texts the ONE next safe step the moment it is due, clock measured from when he ticked the previous one Done. Waking hours only — a step coming due at 3am is still due at 8am. Minute 28 dodges every existing series. log /opt/ghl/logs/ · status /opt/ghl/status/bmwarmup.last | 14x a day (8am-9pm) | 39 min ago | Tue 17:28 | OK |
page Kirk when the voice agent's custom action stops firing voiceaction · voice_action_watch.py WHY THIS EXISTS (Kirk 2026-08-22). Thomas Nahm, a fully-qualified roofing lead, was lost on the very last step of an outbound call. He had answered every question and said nothing would make him cancel. The agent said "let me confirm the pr What it does, in fullvoice_action_watch.py — page Kirk when the voice agent's custom action stops firing. WHY THIS EXISTS (Kirk 2026-08-22). Thomas Nahm, a fully-qualified roofing lead, was lost on the very last step of an outbound call. He had answered every question and said nothing would make him cancel. The agent said "let me confirm the property address I have for you before we look at times, give me one second" — then said nothing at all, and Nedzo's silence timer hung up on him with "I don't hear you, so I'm gonna hang up now." The check_property_address action never fired. Not slow, not a 500: the request never left Nedzo. And it had been dead for FOUR DAYS — Caddy's log shows the live agent runtime last reached /address on Aug 18 at 13:17. Between then and Thomas there were ~40 dial-guard checks and zero action calls. Nothing noticed, because a voice action that stops firing is the quietest failure we have: * dial_guard is a long-running service, not a cron job, so monitor.py — which derives what it watches from the crontab — has never looked at it at all. * A dead action writes no error, no failed run, no log line. It writes SILENCE onto a live call, and silence is indistinguishable from a lead who simply stopped talking. * The only symptom is a booking that doesn't happen, which nobody can see. So the thing to watch is not "did a script fail" but "is the live agent still calling us". Caddy logs every request that reaches guard.ridgerev.com, and the live in-call runtime has a distinct User-Agent from our own curl probes and from the Test URL button in the Nedzo UI. That gives us a true liveness signal for the whole path, end to end, from outside our code. Alert says whether OUR endpoint is healthy, because that is what tells Kirk where to look: endpoint up + action not firing = the fault is in Nedzo's agent config, not on the server. See skills: no-silent-failures, voice-agent-endpoints, resilient-runs. cron 47 9,14,19 * * * /opt/ghl/run.sh voiceaction voice_action_watch.py log /opt/ghl/logs/ · status /opt/ghl/status/voiceaction.last | 3x a day (9am-7pm) | 2h ago | Tue 19:47 | OK |
PART 1: roofers who CALL the Sendivo line = invisible positives, captured sendivo-callins · sendivo_callins.py A lead who CALLS +1 (505) 451-8313 is hotter than one who texts — but Sendivo rewrites the caller ID when forwarding to GHL, so GHL never sees who called. The caller numbers exist only in Sendivo's dashboard call log, which Kirk exports (In What it does, in fullsendivo_callins.py — PART 1: roofers who CALL the Sendivo line = invisible positives, captured. A lead who CALLS +1 (505) 451-8313 is hotter than one who texts — but Sendivo rewrites the caller ID when forwarding to GHL, so GHL never sees who called. The caller numbers exist only in Sendivo's dashboard call log, which Kirk exports (Instant Data Scraper / CSV) into the "Sendivo Incoming Calls" Drive folder. This script pulls the newest export and, for every NEW inbound call from a real number: 1. matches the caller against the cold-SMS CRM (all of a contact's phones), finds their BATCH; 2. tags `cold sms-engaged lead` + `cold sms-called in`, adds a note ("Called into the business …"); 3. TEXTS KIRK ("[Name], batch N, called in — call back"). NO auto-text to the prospect (Kirk's rule); 4. the `cold sms-called in` tag makes tracking_updater count them as a REPLY + POSITIVE for their batch (additive, deduped — see step 5b there), retroactively via the nightly --refresh-all. Idempotent: processed call events are keyed phone|timestamp in STATE; tags/notes are only added once per contact. Unmatched callers (not in the CRM) are reported but never touched. [[resilient-runs]] Usage: python3 scripts/sendivo_callins.py --dry-run # newest Drive export, print matches only python3 scripts/sendivo_callins.py --csv app.csv # explicit file python3 scripts/sendivo_callins.py # normal (cron poller) cron * 7-21 * * * /usr/bin/flock -n /tmp/sendivo_callins.lock /opt/ghl/run.sh sendivo-callins sendivo_callins.py >> /opt/ghl/logs/sendivo_callins.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/sendivo-callins.last | every 1 min, 7am-9pm | just now | Tue 17:08 | OK |
post_booking_research.py postbooking · post_booking_research.py A few minutes after a GROWTH appointment is booked, text the prospect (in Kirk's first-person voice) that he's already started / is continuing researching their market to prep for the session, with a risk-reversal line (worst case, you can What it does, in fullpost_booking_research.py A few minutes after a GROWTH appointment is booked, text the prospect (in Kirk's first-person voice) that he's already started / is continuing researching their market to prep for the session, with a risk-reversal line (worst case, you can implement it yourself). Shows value + effort before the call. FIRST-TIME (their only appointment) -> "I'm already starting my research on ..." CONTINUING (any EARLIER appointment: reschedule / -> "I'm continuing my research on ..." no-show redo / follow-up => appt count > 1) Growth-session calendars only (same 3 as appointment_reminder). Opt-out, send, delivery verification and Kirk's self-report are reused from appointment_reminder (single source of truth). Fires ONCE per appointment (dedup by appointment id in a state file) and is belt-and-suspenders reply-safe: also skips a contact who already got a research text in the last ~20h (dedup phrase 'my research on'). First run (no state file) or --seed: records the current just-booked appointments as handled and sends NOTHING, so enabling it never backfill-spams existing bookings (supervised-release). Cron: every 5 min, 7am-9pm CST -> flock -n /tmp/post_booking.lock run.sh postbooking post_booking_research.py Usage: python3 scripts/post_booking_research.py # live (send) python3 scripts/post_booking_research.py --dry-run # show who'd get what, send nothing python3 scripts/post_booking_research.py --seed # mark current bookings handled, send nothing python3 scripts/post_booking_research.py --window-min 45 # 'just booked' lookback (default 30) cron 2-59/5 7-21 * * * /usr/bin/flock -n /tmp/post_booking.lock /opt/ghl/run.sh postbooking post_booking_research.py >> /opt/ghl/logs/post_booking.log 2>&1 # every 5min 7a-9p CST; ABSOLUTE paths (cron PATH lacks /opt/ghl); LIVE 2026-07-30 log /opt/ghl/logs/ · status /opt/ghl/status/postbooking.last | every 5 min, 7am-9pm | 5 min ago | Tue 17:12 | OK |
read-only Stripe → GHL revenue sync (Kirk 2026-08-04) stripe · stripe_ltv.py Sums each Stripe customer's SUCCEEDED charges (upfront + every recurring payment = true cash collected / LTV-to-date), matches them to the RidgeRev SMS-sub contact by email → phone, and writes two numeric fields: 'Cash Collected' = total co What it does, in fullstripe_ltv.py — read-only Stripe → GHL revenue sync (Kirk 2026-08-04). Sums each Stripe customer's SUCCEEDED charges (upfront + every recurring payment = true cash collected / LTV-to-date), matches them to the RidgeRev SMS-sub contact by email → phone, and writes two numeric fields: 'Cash Collected' = total collected (THE closed-deal signal ltv_tracker + batch_funnel already read — so daily Closes/Cash, closes-per-batch, and the AB test all light up, no new plumbing) 'Upfront Collected' = the first payment (what they paid on the call) Read-only Stripe (rk_live restricted key in /opt/ghl/.env). Idempotent (only writes on change). Unmatched paying customers are reported so Kirk can link them by hand. Cron: daily, BEFORE the 4am growthtrack chain. Usage: stripe_ltv.py [--dry-run] cron 50 3 * * * /opt/ghl/run.sh stripe stripe_ltv.py >> /opt/ghl/logs/stripe.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/stripe.last | daily at 3am:50 | 13h ago | Wed 03:50 | OK |
safety net for RidgeRev meeting links, back on ZOOM (Kirk switched Google… zoomwatch · zoom_link_watch.py on 2026-08-05). Catch any upcoming appointment that is missing its video link OR is still carrying a Google Meet / personal-room (PMI) link across ALL client-facing calendars, mint a fresh UNIQUE Zoom link, write it into the appt `address`, What it does, in fullzoom_link_watch.py — safety net for RidgeRev meeting links, back on ZOOM (Kirk switched Google Meet -> Zoom on 2026-08-05). Catch any upcoming appointment that is missing its video link OR is still carrying a Google Meet / personal-room (PMI) link across ALL client-facing calendars, mint a fresh UNIQUE Zoom link, write it into the appt `address`, and page Kirk if anything can't be fixed — so a call never happens link-less or on the wrong platform. Per upcoming appointment on growth / onboarding / launch / checkin / milestone calendars: - a real UNIQUE zoom.us link -> leave it (correct). - a meet.google.com link -> REPLACE with a fresh Zoom link (the switch-back). - the Zoom PERSONAL ROOM (PMI 9264494397) -> REPLACE (never let a booking point at Kirk's personal room — the original Tom bug, see [[reference_ghl_meeting_link_gotchas]]). - EMPTY / no https link -> mint a Zoom link. AUTO-CREATE via zoom_client.py (S2S OAuth "RidgeRev Link Fixer", creds ZOOM_ACCOUNT_ID/CLIENT_ID/CLIENT_SECRET in server .env, host kirk@ridgerev.com) — type-2 SCHEDULED meetings get a unique id (NOT the PMI). If minting or the creds are missing, it falls back to paging Kirk (never silent). First run = the BACKFILL that flips every upcoming Meet link to Zoom (fresh seen-state so nothing is skipped). Cron: hourly during business hours (label `zoomwatch`). [[no-silent-failures]] [[resilient-runs]] cron */3 8-20 * * * /opt/ghl/run.sh zoomwatch zoom_link_watch.py >> /opt/ghl/logs/zoomwatch.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/zoomwatch.last | every 3 min, 8am-8pm | just now | Tue 17:09 | OK |
schedule_generator.py schedule · schedule_generator.py Reads kirk@ridgerev.com Google Calendar, finds open 1-hour slots in your base availability window (12PM-6PM CST weekdays), and generates an interactive HTML scheduling tool — updated daily, hosted on Dropbox. What it does, in fullschedule_generator.py Reads kirk@ridgerev.com Google Calendar, finds open 1-hour slots in your base availability window (12PM-6PM CST weekdays), and generates an interactive HTML scheduling tool — updated daily, hosted on Dropbox. Usage: python scripts/schedule_generator.py # generate + upload to Dropbox python scripts/schedule_generator.py --days 4 # check next 4 weekdays python scripts/schedule_generator.py --dry-run # preview only, no upload/SMS python scripts/schedule_generator.py --sms # also send SMS summary Output: Dropbox: /B2B Sales Calls/schedule.html (refresh = always latest) Local: 1 - Marketing/schedule.html (fallback) The HTML tool shows available slots by timezone with click-to-book toggles. Booked state persists in browser localStorage between refreshes. cron 0 */2 * * * /opt/ghl/run.sh schedule schedule_generator.py --days 7 >> /opt/ghl/logs/schedule.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/schedule.last | every 2h | 67 min ago | Tue 18:00 | OK |
Scientific Method Template Tracking (Version 1 Hypothesis) vault · reply_vault.py MATCHING RULES (strict): ✅ Exact match ✅ Variable swaps only: - {NAME} → prospect's name (e.g. "Hey Blaine" vs "Hey Wayne") - {CITY} → city name (e.g. "in Kansas City" vs "in Phoenix") - {TIME} → time expression (e.g. "1pm EST" vs "3pm CST" What it does, in fullreply_vault.py -- Scientific Method Template Tracking (Version 1 Hypothesis) MATCHING RULES (strict): ✅ Exact match ✅ Variable swaps only: - {NAME} → prospect's name (e.g. "Hey Blaine" vs "Hey Wayne") - {CITY} → city name (e.g. "in Kansas City" vs "in Phoenix") - {TIME} → time expression (e.g. "1pm EST" vs "3pm CST") - {DAY} → day reference (e.g. "tomorrow" vs "Monday") - ridgerev.com → may be appended (ignore) ❌ Any other differences = NOT a match (different template or improvised) KPI: Appointment Booking Rate (ABR) = bookings / times this exact template was used Sample target: 30 per template before drawing conclusions. cron 30 3 * * * /opt/ghl/run.sh vault reply_vault.py >> /opt/ghl/logs/vault.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/vault.last | daily at 3am:30 | 13h ago | Wed 03:30 | OK |
script_feedback.py scriptfeedback · script_feedback.py Reads call transcripts from Wavv and Fathom, then uses Claude to answer all 10 questions on the Script Feedback Form for both: - Appointment Setting script (Wavv calls) - Growth Call script (Fathom calls with 'growth' in title) What it does, in fullscript_feedback.py Reads call transcripts from Wavv and Fathom, then uses Claude to answer all 10 questions on the Script Feedback Form for both: - Appointment Setting script (Wavv calls) - Growth Call script (Fathom calls with 'growth' in title) Scientific method alignment: tracks Version X of the script, answers the same 10 questions across 30 calls, then logs to the Google Doc. The 10 questions (same form for both scripts): 1. What words/phrases/sentences do prospects keep repeating? 2. Recurring stories from prospects? 3. What makes prospects bored or sad? 4. Common metaphors/analogies prospects use? 5. What are prospects getting excited about? 6. Adjectives prospects use to describe their current situation? 7. Common desires and needs? 8. Common questions prospects ask post-pitch? 9. Main objections? 10. Average call conclusion? (+) Words YOU use that hit home? Usage: python scripts/script_feedback.py --type appt # appt setting calls python scripts/script_feedback.py --type growth # growth/sales calls python scripts/script_feedback.py --type both # both Requires: WAVV_API_KEY, FATHOM_API_KEY, ANTHROPIC_API_KEY in environment. cron 40 7 * * 0 /opt/ghl/run.sh scriptfeedback script_feedback.py --type both --min 3 --csv /tmp/wavv_latest.csv >> /opt/ghl/logs/scriptfeedback.log 2>&1 # weekly appt+growth script feedback (Kirk 2026-08-07); Sonnet+weekly=cost-min; Sun 7:40a clear of 4:05a Fathom block log /opt/ghl/logs/ · status /opt/ghl/status/scriptfeedback.last | Sun 7am | 2d ago | Sun 07:40 | OK |
send a new client a handwritten thank-you card (Handwrytten) giftcard · client_gift_card.py Closes ClickUp "Send the client a gift" (86bb775ty). Reads the client straight from the ClickUp onboarding row so the name and address are never retyped. What it does, in fullclient_gift_card.py — send a new client a handwritten thank-you card (Handwrytten). Closes ClickUp "Send the client a gift" (86bb775ty). Reads the client straight from the ClickUp onboarding row so the name and address are never retyped. SAFETY — this spends real money and mails a physical card to a real customer: • DRY-RUN IS THE DEFAULT. --send is required to place an order, and it also requires --yes. • Refuses to send twice to the same client (state file), so a re-run can't double-order. • Refuses if the message still contains an unfilled {placeholder}. • Refuses if the message exceeds the card's character limit (500 on Black & Cream Thank You). API NOTES (established by probing, 2026-08-09) Base https://api.handwrytten.com/v1 Auth: `Authorization: <api key>` as a RAW header value — NOT "Bearer <key>", and NOT the `key` body field that the public catalog endpoints (/cards/list, /fonts/list) use. Bearer yields 440 "Session has expired"; the body-key yields 401 "no auth". Order: POST /orders/singleStepOrder with card_id (int, snake_case — `cardId` is rejected with the misleading "card id error"), sender_* and recipient_* name/address fields, message, font_id. cron 40 8 * * * /usr/bin/flock -n /tmp/gift_card.lock /opt/ghl/run.sh giftcard client_gift_card.py --auto --send --yes >> /opt/ghl/logs/giftcard.log 2>&1 # handwritten welcome card to NEW clients (Kirk 2026-08-09). Pre-existing rows are seeded as skipped so this can never retro-mail old clients; a bad name/address exits nonzero so run.sh pages Kirk instead of printing it. log /opt/ghl/logs/ · status /opt/ghl/status/giftcard.last | daily at 8am:40 | 1h ago | Wed 08:40 | OK |
SERVER automation: keep the batch queue full, hands-off auto-scrape · auto_scrape.py Runs on a schedule; when the number of UN-ENROLLED batches drops to/below the threshold, it scrapes the next N un-scraped non-TX cities (top-down) and runs the full A-to-Z pipeline (clean → validate → enrich → batch → append-only sheet → GH What it does, in fullauto_scrape.py — SERVER automation: keep the batch queue full, hands-off. Runs on a schedule; when the number of UN-ENROLLED batches drops to/below the threshold, it scrapes the next N un-scraped non-TX cities (top-down) and runs the full A-to-Z pipeline (clean → validate → enrich → batch → append-only sheet → GHL upsert). Otherwise it no-ops cheaply. Idempotent + safe to run daily. Why safe to be hands-off: TX is gated at 3 layers; enrollment is by batch NUMBER (no tag bugs); GHL upsert dedupes by phone; and actual SENDS stay gated by the batch cadence + Wait-gate — new leads only flow into the queue, they don't blast anyone. Queue depth = Batch Queue rows with Enrolled? not TRUE. Threshold default 2 (matches batch_uploader LOW_THRESHOLD). Scrapes ~15 cities/run (~1 batch + tail top-up). Usage (server cron): python3 scripts/auto_scrape.py # check queue; scrape+run if low python3 scripts/auto_scrape.py --threshold 2 --cities 15 python3 scripts/auto_scrape.py --force # scrape+run regardless of depth python3 scripts/auto_scrape.py --check-only # just print queue depth cron 0 7 * * * /opt/ghl/run.sh auto-scrape auto_scrape.py >> /opt/ghl/logs/auto_scrape.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/auto-scrape.last | daily at 7am:00 | 7h ago | Wed 07:00 | OK |
stand up a Brevo sending domain + sender, and produce the exact GHL… brevocards · brevo_setup.py WHAT IS AND ISN'T AUTOMATABLE (verified, not assumed) Brevo side — API. Endpoints confirmed against Brevo's own OpenAPI spec (api.brevo.com/v3/swagger_definition_v3.yml), auth is an `api-key` HEADER: POST /senders/domains create the sending What it does, in fullbrevo_setup.py — stand up a Brevo sending domain + sender, and produce the exact GHL paste-card. WHAT IS AND ISN'T AUTOMATABLE (verified, not assumed) Brevo side — API. Endpoints confirmed against Brevo's own OpenAPI spec (api.brevo.com/v3/swagger_definition_v3.yml), auth is an `api-key` HEADER: POST /senders/domains create the sending domain GET /senders/domains/{domain} the DKIM/DMARC/brevo_code records to publish PUT /senders/domains/{domain}/authenticate ask Brevo to re-check DNS POST /senders create the from-address GET /senders list existing senders GHL side — NO API. Re-confirmed live 2026-08-09, not just by spec-grep: GET /locations/{id} does expose a `defaultEmailService` id (both Sunset and Inspect-A-Deck already have one, and they differ), but there is no collection behind it — /locations/{id}/emailServices, /emails/services, /locations/{id}/email-services all 404. The field only REFERENCES a record the UI must create, so it cannot be used to add a Brevo connection. All 40 GoHighLevel public specs were grepped for smtp/brevo/sendinblue: zero hits. The only email-credential field in their entire API is a `mailgun` object on POST/PUT /locations/. Sub-account Settings > Email Services is UI-only, so the last step is a human paste — this script renders it as a one-screen card so it's 30 seconds, not a hunt. The SMTP relay KEY is also UI-only (Brevo Settings > SMTP & API); their API can mint a sub-account API key on Enterprise only, which is a different credential. Usage: python3 brevo_setup.py --domain leads.ridgerev.com --email noreply@leads.ridgerev.com --from-name "RidgeRev" # dry run, shows the plan python3 brevo_setup.py --domain ... --email ... --live # create domain + sender python3 brevo_setup.py --domain ... --dns # print records to publish python3 brevo_setup.py --domain ... --authenticate --live # re-check DNS after publishing python3 brevo_setup.py --email ... --smtp-login X --smtp-key Y --paste-card # the GHL card cron 50 8 * * * /usr/bin/flock -n /tmp/brevo_cards.lock /opt/ghl/run.sh brevocards brevo_setup.py --ensure --live >> /opt/ghl/logs/brevo.log 2>&1 # post the Brevo SMTP paste-card onto each new client\x27s open Brevo task (Kirk 2026-08-09: one shared sender for all clients; the GHL end has no API). Key is MASKED in the comment; dedupes so it never re-posts. log /opt/ghl/logs/ · status /opt/ghl/status/brevocards.last | daily at 8am:50 | 8h ago | Wed 08:50 | OK |
system_health.py health · system_health.py Weekly system health check for RidgeRev. Checks all active systems and sends Kirk a status SMS. What it does, in fullsystem_health.py Weekly system health check for RidgeRev. Checks all active systems and sends Kirk a status SMS. Run: python scripts/system_health.py cron 0 5 * * 0 /opt/ghl/run.sh health system_health.py >> /opt/ghl/logs/health.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/health.last | Sun 5am | 2d ago | Sun 05:00 | OK |
text Kirk the minute a GROWTH-side prospect calls in growthcallins · growth_callins.py (RidgeRev SALES sub-account — the Sendivo call-in system's sibling, Kirk 2026-07-18.) What it does, in fullgrowth_callins.py — text Kirk the minute a GROWTH-side prospect calls in. (RidgeRev SALES sub-account — the Sendivo call-in system's sibling, Kirk 2026-07-18.) Wavv is GHL-integrated, so inbound calls to his Wavv/LC numbers land in the sales sub's conversations as inbound TYPE_CALL messages (verified 2026-07-18: probe found live inbound call convos). Poll the newest conversations, alert on any inbound call not yet seen. Text-only (Kirk's spec — no tags/notes on this side). Dedup per (conversation, message-timestamp) in outputs/growth_callins_state.json. Cron: every minute 7am-9pm CST with flock (same cadence as sendivo_callins). Zero LLM cost — one GHL search call per run. cron * 7-21 * * * flock -n /tmp/growth_callins.lock /opt/ghl/run.sh growthcallins growth_callins.py >> /opt/ghl/logs/growthcallins.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/growthcallins.last | every 1 min, 7am-9pm | just now | Tue 17:08 | OK |
the client-onboarding orchestrator (Phase 1) onboard · onboard_watch.py WHAT IT DOES (every 10 min, 7a-9p CST, via run.sh): 1. DISCOVER — reads the ClickUp onboarding list and finds every client row. (Intake stays owned by Kirk's MAKE scenario: Google Form -> cloned template row. This NEVER creates a row, so it What it does, in fullonboard_watch.py — the client-onboarding orchestrator (Phase 1). WHAT IT DOES (every 10 min, 7a-9p CST, via run.sh): 1. DISCOVER — reads the ClickUp onboarding list and finds every client row. (Intake stays owned by Kirk's MAKE scenario: Google Form -> cloned template row. This NEVER creates a row, so it can't double up.) 2. PUNCH LIST — for the client's ACTIVE stage, posts a click-by-click comment on each step that needs a human, with THAT client's real values pre-filled. Posted once per task, ever (dedup by marker + state file). 3. VERIFY-TICK— closes parent/rollup tasks whose children are all genuinely closed. Ticks ONLY what it can prove from live data — never on assumption (Kirk 2026-08-08: "tick only what it can verify"). 4. STUCK — texts Kirk once when a client's active stage stops moving. PHASE 1 SCOPE: ClickUp only. It sends NOTHING to a client and touches no GHL/Meta/Slack. The only side effects are on Kirk's own board. Later phases add the doers (GHL sub-account customizer, ROI sheet, ad monitor) behind the same registry. SAFETY • DRY-RUN IS THE DEFAULT. Pass --live to write to ClickUp. • --seed marks every existing client as OBSERVED without posting anything, so turning this on never blasts comments over historical clients (supervised-release). • MAX_ACTIONS caps how much one run can do, so a bad deploy damages little. Usage: python3 onboard_watch.py --dry-run # default: show what it WOULD do python3 onboard_watch.py --seed --live # baseline existing clients, no comments python3 onboard_watch.py --live # real run (cron) python3 onboard_watch.py --client "Inspect" # limit to one client cron 8-58/30 7-21 * * * /usr/bin/flock -n /tmp/onboard_watch.lock /opt/ghl/run.sh onboard onboard_watch.py --live >> /opt/ghl/logs/onboard.log 2>&1 # client onboarding orchestrator (Kirk 2026-08-09): punch-lists + verify-tick + stuck alerts log /opt/ghl/logs/ · status /opt/ghl/status/onboard.last | every 30 min, 7am-9pm | 29 min ago | Tue 17:08 | OK |
the weekly "become dangerous at sales" coaching digest dangerous · sales_dangerous.py Synthesizes ALL of Kirk's growth-call data into ONE prescriptive weekly plan grounded in the $100M Closing Playbook + ACQ Closer Handbook (see [[reference-100m-closing]]): What it does, in fullsales_dangerous.py — the weekly "become dangerous at sales" coaching digest. Synthesizes ALL of Kirk's growth-call data into ONE prescriptive weekly plan grounded in the $100M Closing Playbook + ACQ Closer Handbook (see [[reference-100m-closing]]): data in → objection_tracker (objections faced / handled / weighted-recent), Handle A/B (which rebuttals work, how often Kirk IMPROVISES vs uses handles), Q&A handle rate, show_tracker funnel (show rate / model-fit / cancel-model). brain → the two books condensed into a coaching system prompt. plan out → top weakness + THE drill + the principle + 3 practice reps + one mindset — written to a "Sales Dangerous" tab (Growth Call Tracker sheet, newest on top) and texted to Kirk. Usage: python scripts/sales_dangerous.py # generate + write tab + text Kirk python scripts/sales_dangerous.py --dry-run # print only cron 0 16 * * 0 /opt/ghl/run.sh dangerous sales_dangerous.py >> /opt/ghl/logs/dangerous.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/dangerous.last | Sun 4pm | 2d ago | Sun 16:00 | OK |
tracking_updater.py tracking · tracking_updater.py Automatically updates the monthly SMS tracking sheet with batch stats. Pulls from: Sendivo API (sends), GHL API (replies/positive/booked), Claude (reply classification), Google Sheets (write results). What it does, in fulltracking_updater.py Automatically updates the monthly SMS tracking sheet with batch stats. Pulls from: Sendivo API (sends), GHL API (replies/positive/booked), Claude (reply classification), Google Sheets (write results). Usage: python scripts/tracking_updater.py --master-batch 4 python scripts/tracking_updater.py --master-batch 4 --month july python scripts/tracking_updater.py --master-batch 4 --dry-run Master batch → month sheet mapping: June: master batches 1-2 (rows 12-13) July: master batches 3+ (rows 12+) cron 30 2 * * * /opt/ghl/run.sh tracking tracking_updater.py --refresh-all >> /opt/ghl/logs/tracking.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/tracking.last | daily at 2am:30 | 14h ago | Wed 02:30 | OK |
will the batch queue still be full NEXT week queue-runway · queue_runway.py WHY THIS EXISTS On 2026-08-11 the queue hit ZERO and sending was one send from stopping. auto_scrape had run fine that morning; it was simply producing less than we consume. The existing alert fires on batches REMAINING, so it can only spea What it does, in fullqueue_runway.py — will the batch queue still be full NEXT week? Alert on the TREND, not the wall. WHY THIS EXISTS On 2026-08-11 the queue hit ZERO and sending was one send from stopping. auto_scrape had run fine that morning; it was simply producing less than we consume. The existing alert fires on batches REMAINING, so it can only speak once the queue is already empty — no slack left. This checks the RATE instead. Consumption is measured from the Batch Queue's own "Enrolled At" stamps (one ~580 batch per business day, historically), production from the scrape's actual yield. If runway is short, that is knowable days ahead — which is when it is still cheap to fix. Usage: python3 queue_runway.py # report python3 queue_runway.py --notify # ...and text Kirk if the runway is short cron 15 9 * * * /usr/bin/flock -n /tmp/queue_runway.lock /opt/ghl/run.sh queue-runway queue_runway.py --notify >> /opt/ghl/logs/auto_scrape.log 2>&1 # forward-looking batch-queue check (Kirk 2026-08-11): alerts on the RATE — days of runway left — instead of waiting for the queue to hit zero. Runs AFTER the 07:00 auto-scrape so it only speaks if scraping did not fix it. log /opt/ghl/logs/ · status /opt/ghl/status/queue-runway.last | daily at 9am:15 | 7h ago | Wed 09:15 | OK |
Rebuilt every 15 minutes · this page last built Tue 25 Aug 2026, 17:07 server time
· the page reloads itself every 15 min.
You are only paged when something here is failing. If nothing texted you, nothing is broken.