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.
| Automation | Runs | Last run | Next | State |
|---|---|---|---|---|
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." (not texting Kirk: 9 free is above the 3 where it starts to matter — the log and the board still show it) 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 | 8h ago | Sun 07:51 | FAILED |
is this client actually wired up, measured against what we DECLARED clientready · client_readiness.py Kirk 2026-09-27: *"ensure all new clients added to this automatically as well."* ⚠ CLIENT SETUP — 2 client(s) are missing something the manifest says they must have: HDrdrryX — register them, o What it does, in fullclient_readiness.py — is this client actually wired up, measured against what we DECLARED? Kirk 2026-09-27: *"ensure all new clients added to this automatically as well."* ⛔ subaccount_parity CANNOT ANSWER THIS, AND DELIBERATELY SO. Its baseline is consensus — "if an asset exists in every OTHER active account and not in this one, that is a gap" — which is the right rule for GHL assets, because a hardcoded reference goes stale the moment somebody improves a real account. But it has two blind spots by construction: 1. something missing from EVERY client looks like agreement. All three were missing the disposition custom fields from the manifest and consensus called it clean. 2. it only sees GHL assets. The Counting flag is a SHEET CELL, the Submissions tab is a SHEET TAB, the date format is a SHEET FORMAT, and the subscription is in Stripe. None of them is a workflow or a form, so none of them is ever compared. So this checks each client against `client_config_manifest.json` — what we said is required — rather than against the other clients. The two watches answer different questions and both are needed. ⛔ A DECLARATION NOBODY CHECKS IS A COMMENT. Writing the requirement into the manifest was the easy half; this is the half that makes it true. Found 2026-09-27 by looking for the reader and finding there wasn't one. ⛔ AND IT REPORTS, IT NEVER REPAIRS. The repairs live in the jobs that own them — tracker_dates paints the columns, submissions_log creates its tab. A watch that fixes things hides how often it has to. python3 client_readiness.py python3 client_readiness.py --live # page Kirk when something is missing python3 client_readiness.py --selftest cron 22 8 * * * /usr/bin/flock -n /tmp/client_readiness.lock /opt/ghl/run.sh clientready client_readiness.py --live >> /opt/ghl/logs/client_readiness.log 2>&1 # is every client wired up per client_config_manifest.json? ⛔ subaccount_parity CANNOT answer this — its baseline is consensus, so something missing from EVERY client looks like agreement, and it only sees GHL assets, not sheet cells, tabs, formats or Stripe. 08:22 sits just before parity at 08:17+ so both land in one morning read. log /opt/ghl/logs/ · status /opt/ghl/status/clientready.last | daily at 8am:22 | 7h ago | Sun 08:22 | FAILED |
one hidden tab per client: what the machine has decided, and what is… whatsnext · whats_next.py Kirk 2026-09-29: *"i need a sheet that has all the messages scheduled to go out to these guys... before they are sent so i can see all at a glance"* and then *"add it to the tracking sheet... any way to put that decision making layer in the 4 client(s) · 1 problem(s) What it does, in fullwhats_next.py — one hidden tab per client: what the machine has decided, and what is queued. Kirk 2026-09-29: *"i need a sheet that has all the messages scheduled to go out to these guys... before they are sent so i can see all at a glance"* and then *"add it to the tracking sheet... any way to put that decision making layer in the tracking sheet too and have it constantly updated so it can be visible?"* So it goes on each client's own tracker, as `What happens next` — hidden, like `To send`, which is where the last "put them in the tracking sheet" landed. Rebuilt every 20 minutes. ⛔ READ-ONLY. Nothing here sends, approves, extends or bills. It is the window, not the lever — you still type SEND and OK exactly where you always did. A second place to approve something is a second thing that can disagree about whether it was approved. ⛔ IT ASKS THE SENDERS, IT DOES NOT RE-DECIDE. Every line comes from calling the code that actually owns the decision — `cycle_notice.state_of`, `cycle_state.verdict`, `cycle_notice.due`, the `cycle_messages` composers, `terms_reminder.due`/`draft` — or from reading what a sender has already written down (`to_send`'s tab). ⛔ Two things computing one verdict is two things that can disagree about one client in one week, which this codebase has already had once between `cycle_alert.main()` and `client_states()`. The only thing this file decides is which function to ask. ⛔ "HELD" IS A DECISION, NOT AN ABSENCE. `state_of` returns nothing for a client still on the old promise, and a tab that rendered that as blank would read as "nothing happening" when the truth is "everything for this client is waiting on you typing GO". The reason is printed. ⛔ AND WHAT IS NOT AUTOMATIC IS SAID PLAINLY ON THE TAB. The machine decides to extend but cannot write the date; it proposes a Stripe move but cannot apply it without a typed OK; and nothing freezes delivery on a failed card. A panel that shows a decision without saying who has to act on it reads as though it already acted. python3 whats_next.py # DRY python3 whats_next.py --live python3 whats_next.py --selftest cron 19-59/20 * * * * /usr/bin/flock -n /tmp/whats_next.lock /opt/ghl/run.sh whatsnext whats_next.py --live >> /opt/ghl/logs/whats_next.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/whatsnext.last | every 20 min, all day | 3 min ago | Sat 16:39 | FAILED |
| 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 | 3 |
silent success OK, while saying it did nothing | 1 |
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 | 1 |
cron collisions two jobs in the same minute | 1 |
client offload telling a roofer to chase his own lead | 9 |
duplicate crons the same line entered twice | 0 |
AUDIT: 16 finding(s), 8 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) / daily at 10am:23 | 5h ago | Sat 17:25 | OK |
20 minutes before a growth call, tell Kirk to prep for it growthprep · growth_prep_nudge.py Kirk 2026-10-07: *"add me a 20 min reminder to prepare before every growth call I have."* What it does, in fullgrowth_prep_nudge.py — 20 minutes before a growth call, tell Kirk to prep for it. Kirk 2026-10-07: *"add me a 20 min reminder to prepare before every growth call I have."* ⛔ THE CALENDARS, THE STATUSES AND THE FETCHER ARE IMPORTED, NEVER RE-TYPED. `appointment_reminder` is already the single source of truth for which three calendars are a growth session, and `post_booking_research` imports them from there for exactly this reason. A fourth copy of three calendar ids is a fourth thing to forget when a calendar is renamed, and the one that forgets is silently the one that reminds him about nothing. [[derive-dont-hardcode]] ⛔ EVERY FIVE MINUTES, BECAUSE THE SWEEP IS THE REAL CLOCK. This is the lesson already paid for on `group_intro` (Kirk 2026-09-08): a 20-minute promise driven by a 15-minute sweep is really 20-35 minutes, because an appointment that crosses its mark at :09 waits for the :18 pass. A five-minute sweep makes a 20-minute reminder land 20-25 minutes out. ⛔ AND IT FIRES EARLY RATHER THAN LATE. `FIRE_WITHIN` is 25, not 20: the moment a call is inside 25 minutes the nudge goes. For a reminder whose whole job is to buy him time to prepare, arriving early is the harmless direction and arriving late is the useless one. ⛔ IT PAGES KIRK, AND KIRK ONLY. Nothing here can reach a prospect, so the standing rule *"dont send off any mesages without me"* is not in play — but there is also no send path to a contact in this file at all, deliberately, so a later edit cannot grow one by accident. Usage: python3 scripts/growth_prep_nudge.py # live python3 scripts/growth_prep_nudge.py --dry-run # show who'd be nudged, send nothing python3 scripts/growth_prep_nudge.py --seed # mark today's calls handled, send nothing python3 scripts/growth_prep_nudge.py --selftest Cron: */5 7-21 * * * -> flock -n /tmp/growth_prep.lock run.sh growthprep growth_prep_nudge.py cron 4-59/5 6-21 * * * /usr/bin/flock -n /tmp/growth_prep.lock /opt/ghl/run.sh growthprep growth_prep_nudge.py >> /opt/ghl/logs/growth_prep.log 2>&1 # Kirk 2026-10-07: 20-min prep nudge before every growth call. EVERY 5 MIN because the sweep is the real clock (the group_intro lesson: a 20-min promise on a 15-min sweep is really 20-35). Fires as soon as a call is inside 25 min, so early rather than late. From 6am, NOT 7 like postbooking: a 7am call needs its nudge at 6:40. Minute 4-59/5 to sit off postbooking own 2-59/5 slot. log /opt/ghl/logs/ · status /opt/ghl/status/growthprep.last | every 5 min, 6am-9pm | 3 min ago | Sat 16:24 | OK |
4-way SMS WRAPPER A/B split-test tracker (Kirk 2026-07-21) abtest-a1 · 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_all_sms.sh abtest ab_test_tracker.py >> /opt/ghl/logs/abtest.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/abtest-a1.last | daily at 6am:40 | 9h ago | Sun 06:40 | OK |
4-way SMS WRAPPER A/B split-test tracker (Kirk 2026-07-21) abtest-a2 · 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_all_sms.sh abtest ab_test_tracker.py >> /opt/ghl/logs/abtest.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/abtest-a2.last | daily at 6am:40 | 9h ago | Sun 06:40 | OK |
a booking date that stops counting because nobody painted the cell trackerdates · tracker_dates.py Kirk 2026-09-27: *"the appt booked date format seems to be off for some trackers, fix forever please... current and future."* What it does, in fulltracker_dates.py — a booking date that stops counting because nobody painted the cell. Kirk 2026-09-27: *"the appt booked date format seems to be off for some trackers, fix forever please... current and future."* ⛔ THE FAULT IS LATENT, NOT VISIBLE. Every Date Booked cell on every tracker holds a RAW SERIAL NUMBER — 46248.395833 — and reads as a date only because a number format is painted over it. Read back formatted it says "8/14/2026" and parses; read raw it is a float. ⛔ SO A ROW APPENDED BELOW THE PAINTED RANGE DISPLAYS AS "46288.80694", parse_date returns None, and that booking SILENTLY STOPS COUNTING. It is not an error anywhere: cycle_tracker reports "N row(s) not counted" and carries on, the cycle total is quietly one short, and the only symptom is a number that is wrong in the direction of Kirk owing more. It has already happened once, to Inspect-A-Deck rows 21 and 22, and was fixed by hand. ⛔ TWO HALVES, AND ONLY ONE OF THEM IS THE REPAIR. FOREVER the column's number format is set on the WHOLE column, so every future row inherits it the moment GHL appends one. That is the half that stops it recurring. NOW any cell that does not parse today is decoded from its serial and written back. ⛔ THE SHEETS EPOCH IS 1899-12-30, NOT 1900-01-01. Sheets carries Lotus 1-2-3's leap-year bug, so the naive epoch is two days out — and two days is enough to move a booking into the wrong cycle, which moves what a client is owed. ⛔ A CELL THAT ALREADY PARSES IS NEVER TOUCHED. The roofer may have typed it, and a rewrite of a human's own entry is how you lose the one record that was right. python3 tracker_dates.py # what it would repair python3 tracker_dates.py --live python3 tracker_dates.py --selftest cron 1 23 * * * /usr/bin/flock -n /tmp/tracker_dates.lock /opt/ghl/run.sh trackerdates tracker_dates.py --live >> /opt/ghl/logs/tracker_dates.log 2>&1 # GHL writes Date Booked as a RAW SERIAL that only reads as a date because a format is painted over it. A row appended below the painted range shows as 46288.80694, parse_date returns None, and that booking silently stops counting. Sets the whole-column format so every future row inherits it, and repairs any cell that slipped through. ⛔ 23:01 runs FIRST in the chain — everything after it reads these dates. log /opt/ghl/logs/ · status /opt/ghl/status/trackerdates.last | daily at 11pm:01 | 17h ago | Sat 23:01 | OK |
a booking marked `invalid` stops sending, including from GHL's workflows invalidcleanup · invalid_cleanup.py Kirk 2026-09-22: *"invalidated appointments should automatically be removed."* Said after the third one in three days, and he is right that it should never have been hand-work. What it does, in fullinvalid_cleanup.py — a booking marked `invalid` stops sending, including from GHL's workflows. Kirk 2026-09-22: *"invalidated appointments should automatically be removed."* Said after the third one in three days, and he is right that it should never have been hand-work. ⛔ WHAT `invalid` ALREADY DOES, AND THE ONE THING IT DOES NOT. Every automation WE run skips `invalid` — swept 2026-09-21 across sixteen scripts. That is the whole reason Kirk uses it instead of `cancelled`: `cancelled` makes cancel_watch text the roofer "X has cancelled", and a booking made in error must not do that. So marking it invalid is already silent on our side. GHL's own booking workflows are the gap. They fire on the BOOKING and carry a delayed step — the roofer's "A new inspection appointment has been booked for ..." lands about fifteen minutes later. Nothing in that workflow re-reads the appointment, so marking it invalid at minute three does not stop the notice at minute fifteen. The contact has to be pulled out of the workflow, and the only route is DELETE /contacts/{id}/workflow/{wid}. ⛔ AND HERE IS THE TRAP THAT COST TOM HIS NOTIFICATION, WHICH THIS FILE EXISTS TO NOT REPEAT. Rick Hill, 2026-09-22: Kirk booked Tuesday 1pm by mistake, rebooked Thursday 2pm 49 seconds later, and marked the Tuesday one invalid. Both bookings were the SAME CONTACT, so both workflow runs belonged to him. Removing him from the workflows killed the wrong Tuesday notice AND the correct Thursday one — GHL gives no way to cancel one run of a workflow, only to remove the contact from all of them. Tom was told nothing at all, and Kirk had to ask why. So the rule is not "remove them". It is: remove the contact from the booking workflows, and THEN, if they still hold a live appointment, send the roofer that booking's notice ourselves. The second half is not optional. Without it this file silently converts "the roofer got the wrong date" into "the roofer got nothing", which is worse — a wrong date gets questioned, silence does not. ⛔ IT WILL NOT ALWAYS WIN THE RACE, AND SAYS SO. The roofer's notice fires ~15 min after booking and this runs every 10, so a booking invalidated 14 minutes in may already have gone out. That is why `roofer_told()` checks the roofer's own thread before sending rather than assuming: if GHL already named this homeowner, we do not send a second notice, and the run prints that it was too late so the wrong date is a known fact rather than a surprise. [[no-silent-failures]] python3 invalid_cleanup.py # report only, changes nothing python3 invalid_cleanup.py --live # actually remove + notify python3 invalid_cleanup.py --seed # mark today's invalids handled, send nothing python3 invalid_cleanup.py --selftest See skills: no-silent-failures, sweep-the-principle, supervised-release, prove-it-runs-live. cron 1-51/10 * * * * /usr/bin/flock -n /tmp/invalid_cleanup.lock /opt/ghl/run.sh invalidcleanup invalid_cleanup.py --live >> /opt/ghl/logs/invalid_cleanup.log 2>&1 # invalidated booking -> pull contact from GHL booking workflows; re-send the roofer notice if a live appt remains (Kirk 2026-09-22) log /opt/ghl/logs/ · status /opt/ghl/status/invalidcleanup.last | every 10 min, all day | just now | Sat 16:31 | 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 5-55/10 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 10 min, 7am-9pm | 7 min ago | Sat 16:25 | 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 "inspection booked" OR "appt cancelled/rescheduled". A stray "can we reschedule?" from someone who never had an inspection does nothing at all. - the appointment must be in the FUTURE. We never touch history. A homeowner whose visit has ALREADY happened is intercepted by answer_after_visit before any of this can reach them, and is answered about the work rather than about the appointment. - 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). LIVE since 2026-08-26 (Kirk: you can go live right now). It CANCELS live appointments; roofer texts are gated PER SUB-ACCOUNT by /opt/ghl/state/cancel_watch.hold. 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 | Sat 16:37 | OK |
a no-show on the tracker starts the chase to get them rebooked noshowrecovery · noshow_recovery.py Kirk 2026-10-07: *"whenever there is a no show, automatic — if there's a no show marked on the tracker, add them to a follow up ladder to get them rescheduled. If it's like a cancellation or someone rescheduled, just do what we do now, whic What it does, in fullnoshow_recovery.py — a no-show on the tracker starts the chase to get them rebooked. Kirk 2026-10-07: *"whenever there is a no show, automatic — if there's a no show marked on the tracker, add them to a follow up ladder to get them rescheduled. If it's like a cancellation or someone rescheduled, just do what we do now, which is sending the roofer the same message to give them a call and get another time."* ⛔ THE TWO CASES ARE DIFFERENT AND THIS SCRIPT ONLY OWNS ONE OF THEM. · CANCEL / RESCHEDULE -> the roofer rings them. `cancel_watch` already does that, and `reschedule_bot.SEND_HOMEOWNER` stays False so we do not text over the top of his call. Both Kat and Christine were rebooked by their roofer within hours while our ladder was still composing offers. · NO-SHOW -> nobody has spoken to that homeowner, the roofer has already driven out once, and there is no conversation for him to pick back up. That one we chase. ⛔ THE PIPELINE STAGE IS NOT THIS SCRIPT'S JOB AND MUST NOT BE DONE TWICE. `dispo_pipeline` has moved a no-show to the No Show stage since 2026-09-18 and a cancel/reschedule to Cancel / Reschedule since before that, on its own cron every ten minutes, for every client. A second writer of the same stage is how two jobs come to disagree about one opportunity. This script reads the same rule and arms the ladder; it changes no stage. [[derive-dont-hardcode]] ⛔ THE RULE IS IMPORTED, NEVER RE-TYPED. `dispo_pipeline.target_stage` already encodes Kirk's precedence and it is subtle: the field keeps EVERY value ever ticked, so Thomas Nahm reads ['no-show', 'estimate in progress'] — a no-show on the first visit and an estimate on the second. A naive "is no-show in the list" would chase a finished customer. Attendance is column L's call; a Show of Yes beats a no-show disposition outright. python3 noshow_recovery.py # who would be chased, nothing armed python3 noshow_recovery.py --live python3 noshow_recovery.py --selftest cron 18 8-20 * * * /usr/bin/flock -n /tmp/noshow_recovery.lock /opt/ghl/run.sh noshowrecovery noshow_recovery.py --live >> /opt/ghl/logs/noshow_recovery.log 2>&1 # Kirk 2026-10-07: a no-show marked on the tracker starts the rebook chase. Hourly is plenty - a no-show is found once and armed once, and the ladder itself runs on reschedule_bot every 15 min. ONE ROW PER HOMEOWNER, latest wins: Allen Trotter and Peggy Christian each had a no-show AND a later booking they kept. Cancels are NOT chased here - the roofer rings those. log /opt/ghl/logs/ · status /opt/ghl/status/noshowrecovery.last | 13x a day (8am-8pm) | 3 min ago | Sat 17:18 | 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. b2c_client_content scrapes the client's own site for the facts the homeowner texts use 5. client_roi_sheet the ROI tracker, linked back into the sub-account + ClickUp 6. voice_agent_setup PREFLIGHTS the sub-account for everything the voice agents read, and renders this client's five prompts 7. voice_agent_build the five GHL Voice AI agents themselves — prompts, settings and CAP actions, each read back to prove it took. REFUSES to build any of them until the sub-account holds what they read at call time, and says which thing is missing rather than half-building 8. brevo_setup --ensure UI automation, falling back to posting the paste-card 9. 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. • A step that exits EX_PENDING (75) is WAITING, not broken: nothing is recorded, the client is not marked finished, the steps after it still run, and the next pass tries again. • 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) | 2 min ago | Sat 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 * * * * /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. 24/7 since 2026-09-20 (Kirk: send it right after the booking, not next morning) - the clock guard is off for this one text, every other guard stays. log /opt/ghl/logs/ · status /opt/ghl/status/b2cpostbook.last | every 10 min, all day | 6 min ago | Sat 16:26 | 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 | 21h ago | Sat 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 | 7h ago | Sun 09:03 | OK |
Ask Kirk for the month's spend on the last day of the month bizhealth · business_health_reminder.py Kirk 2026-08-30: *"I'll provide my transactions on a monthly basis at the end of the month... send me the reminder each month, and I'll paste the COGS, CAC and overhead... and you tell me the figures and I'll plug them into the health tab." What it does, in fullAsk Kirk for the month's spend on the last day of the month. Asks only; writes nothing. Kirk 2026-08-30: *"I'll provide my transactions on a monthly basis at the end of the month... send me the reminder each month, and I'll paste the COGS, CAC and overhead... and you tell me the figures and I'll plug them into the health tab."* ⛔ THERE IS NOTHING TO AUTOMATE HERE BEYOND THE ASKING. Copilot Money has no API, no MCP server and no Zapier — checked against their own site and help centre. The paste IS the integration, so this job's entire purpose is to make sure the paste happens while the month is still fresh. It reads the calendar and nothing else, and it can never touch the tracker. python3 business_health_reminder.py # dry: say whether today is the day python3 business_health_reminder.py --live # send it if it is python3 business_health_reminder.py --live --force cron 44 17 * * * /usr/bin/flock -n /tmp/bizhealth.lock /opt/ghl/run.sh bizhealth business_health_reminder.py --live >> /opt/ghl/logs/business_health.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/bizhealth.last | daily at 5pm:44 | 22h ago | Sat 17:44 | 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 | 13 min ago | Sat 16:39 | 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 | G Account Kirk edits this tab to add future batches / adjust dates. The routine reads it. ⛔ COLUMN G (Account) IS WHAT KEEPS TWO SENDS APART. There are two cold-SMS sub-accounts now — 1 = RidgeRev SMS (Jen, round one) and 2 = RidgeRev SMS 2 (Amy, the round-two re-hit built by sms_rehit.py) — and the re-hit KEEPS account 1's batch numbers, so "batch 12" alone no longer identifies a send. A blank cell means account 1, because every row written before column G existed is a round-one row. A run only ever sees its own account's rows. 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 --account {1,2} which cold-SMS sub-account (default 1 — the nightly cron) --selftest assertions, including live proof each account's workflow is its own 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 | Sun 02:00 | OK |
batch_uploader.py batch2 · 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 | G Account Kirk edits this tab to add future batches / adjust dates. The routine reads it. ⛔ COLUMN G (Account) IS WHAT KEEPS TWO SENDS APART. There are two cold-SMS sub-accounts now — 1 = RidgeRev SMS (Jen, round one) and 2 = RidgeRev SMS 2 (Amy, the round-two re-hit built by sms_rehit.py) — and the re-hit KEEPS account 1's batch numbers, so "batch 12" alone no longer identifies a send. A blank cell means account 1, because every row written before column G existed is a round-one row. A run only ever sees its own account's rows. 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 --account {1,2} which cold-SMS sub-account (default 1 — the nightly cron) --selftest assertions, including live proof each account's workflow is its own cron 10 2 * * * /opt/ghl/run.sh batch2 batch_uploader.py --auto --account 2 >> /opt/ghl/logs/batch.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/batch2.last | daily at 2am:10 | 14h ago | Sun 02:10 | 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 | Sat 16:34 | OK |
build a client's onboarding row in ClickUp from the template onboardsync · 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 25 22 * * * /usr/bin/flock -n /tmp/onboard_sync.lock /opt/ghl/run.sh onboardsync onboard_row.py --sync-all --live >> /opt/ghl/logs/onboard_sync.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/onboardsync.last | daily at 10pm:25 | 17h ago | Sat 22:25 | 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." (not texting Kirk: 9 free is above the 3 where it starts to matter — the log and the board still show it) 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 | 8h ago | Sun 07:51 | FAILED |
build a pool of blank sub-accounts to get A2P certified in advance a2pbuffer · 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 33 7 * * 1,4 /usr/bin/flock -n /tmp/a2p_buffer.lock /opt/ghl/run.sh a2pbuffer subaccount_pool.py --a2p-buffer --live --yes >> /opt/ghl/logs/a2p_buffer.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/a2pbuffer.last | Mon, Thu 7am | 2d ago | Mon 07:33 | 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 | 22 min ago | Sat 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 | 10h ago | Sun 05:20 | OK |
catch a client's lead being told the wrong thing, within the hour msgguard · client_message_guard.py Kirk 2026-09-06, after a homeowner of ATLAS Roofing was texted "it's Jess from Inspect A Deck Roofing": *"never let this happen ever again with any future clients, and evaluate all your other messages that will come across future clients' l What it does, in fullclient_message_guard.py — catch a client's lead being told the wrong thing, within the hour. Kirk 2026-09-06, after a homeowner of ATLAS Roofing was texted "it's Jess from Inspect A Deck Roofing": *"never let this happen ever again with any future clients, and evaluate all your other messages that will come across future clients' leads and ensure they are all customized."* ⛔ WHAT ACTUALLY WENT WRONG, because the fix has to match it. Our own senders were never at fault: first_text, b2c_post_booking and reschedule_bot all render {company}/{owner}/{area} from the client's own sub-account. The bad copy was TYPED INTO A GHL WORKFLOW — Atlas's workflow had been copied from Inspect-A-Deck's and the company name left as literal text instead of {{custom_values.company_name}}. Three real homeowners had it over two days and nothing said a word. ⛔ SO THIS IS DETECTION, NOT PREVENTION, AND THAT IS NOT A CHOICE. GHL exposes no API to read or write a workflow's message bodies, so the copy cannot be checked before it sends. What is reachable is what WENT OUT. That turns "a client's homeowner was told a competitor's name for two days" into "Kirk knows within the hour", which is the whole difference available to us here. ⛔ AND IT NEVER ASKED WHETHER THE MESSAGE ARRIVED. Kirk 2026-09-25, after Atlas's new-lead hand-off to his own phone was blocked by the carrier ("Error 30007 - Message blocked due to carrier policies") and the only thing that noticed was him opening the conversation: *"got to be every min though, speed to lead"*. Behind that one, two Atlas homeowners had already gone dark the same way and nothing ever said a word — Tomeka Petty, five texts undelivered Sep 9-14; Cliff Tarver, both of his on Sep 18. GHL returns 201 for the send and the failure lands later, in a `status` field nothing here read. [[no-silent-failures]] WHAT IT FLAGS, on outbound messages only: • ANOTHER CLIENT'S NAME — the Atlas/Inspect-A-Deck failure, the one that costs trust • an unrendered merge field — "{{contact.first_name}}" or "{company}" reaching a homeowner • a blank name — "Hey , it's Jess" (one of the four Atlas texts did this) • a stray separator — "870-551-8424 - ." where an empty custom value left a dash hanging. Atlas's "Area Code [City], [State Abbreviation]" was empty; Inspect-A-Deck's said "Charlotte, NC". • A LEAD HAND-OFF THAT NEVER — the new-lead text to Kirk blocked by the carrier. Urgent, and ARRIVED the replacement carries the lead's NAME AND NUMBER, because an alert that only says "a text failed" costs the lead anyway. • A MESSAGE THAT NEVER — any outbound SMS left failed/undelivered/rejected, with the ARRIVED carrier's own reason, and how many in a row to that person. TWO MODES, AND THE FAST ONE IS THE POINT: --since-last every minute. Reads ONLY what changed since the last run: one conversation-list call per client, then messages only for the threads that moved. A quiet minute costs four API calls and no LLM — there is no LLM anywhere in this script. The watermark, the client names and the awaiting-status list are all cached on disk between runs. [[caching]] --hours N the full sweep, unchanged, still hourly as the backstop in case a watermark run was skipped. `--hours 720` prints the historic list of everything that never arrived, without texting anyone. Usage: python3 client_message_guard.py # report only python3 client_message_guard.py --live # ...and text Kirk anything new python3 client_message_guard.py --hours 72 # widen the window (default 24) python3 client_message_guard.py --since-last --live # the per-minute run python3 client_message_guard.py --selftest cron 57 8-21 * * * /usr/bin/flock -n /tmp/client_message_guard.lock /opt/ghl/run.sh msgguard client_message_guard.py --hours 3 --live >> /opt/ghl/logs/client_message_guard.log 2>&1 # catch a clients lead being told the wrong thing, within the hour (Kirk 2026-09-06, "never let this happen ever again with any future clients"). Three real Atlas homeowners were texted "it's Jess from Inspect A Deck Roofing" over two days and nothing said a word; Sunset Siding has the same typed-in name. Flags another clients name, an unrendered merge field, a blank "Hey ," and a separator left dangling by an empty custom value. Our own senders were never at fault - they render {company} per sub-account - the bad copy is typed into GHL workflows, which no API can read, so this checks what WENT OUT. Minute 57 is unused by every explicit series here; it coincides only with the light zoomwatch, and flock stops it overlapping itself. log /opt/ghl/logs/ · status /opt/ghl/status/msgguard.last | 14x a day (8am-9pm) | 25 min ago | Sat 16:57 | OK |
catch a client's lead being told the wrong thing, within the hour msgguardfast · client_message_guard.py Kirk 2026-09-06, after a homeowner of ATLAS Roofing was texted "it's Jess from Inspect A Deck Roofing": *"never let this happen ever again with any future clients, and evaluate all your other messages that will come across future clients' l What it does, in fullclient_message_guard.py — catch a client's lead being told the wrong thing, within the hour. Kirk 2026-09-06, after a homeowner of ATLAS Roofing was texted "it's Jess from Inspect A Deck Roofing": *"never let this happen ever again with any future clients, and evaluate all your other messages that will come across future clients' leads and ensure they are all customized."* ⛔ WHAT ACTUALLY WENT WRONG, because the fix has to match it. Our own senders were never at fault: first_text, b2c_post_booking and reschedule_bot all render {company}/{owner}/{area} from the client's own sub-account. The bad copy was TYPED INTO A GHL WORKFLOW — Atlas's workflow had been copied from Inspect-A-Deck's and the company name left as literal text instead of {{custom_values.company_name}}. Three real homeowners had it over two days and nothing said a word. ⛔ SO THIS IS DETECTION, NOT PREVENTION, AND THAT IS NOT A CHOICE. GHL exposes no API to read or write a workflow's message bodies, so the copy cannot be checked before it sends. What is reachable is what WENT OUT. That turns "a client's homeowner was told a competitor's name for two days" into "Kirk knows within the hour", which is the whole difference available to us here. ⛔ AND IT NEVER ASKED WHETHER THE MESSAGE ARRIVED. Kirk 2026-09-25, after Atlas's new-lead hand-off to his own phone was blocked by the carrier ("Error 30007 - Message blocked due to carrier policies") and the only thing that noticed was him opening the conversation: *"got to be every min though, speed to lead"*. Behind that one, two Atlas homeowners had already gone dark the same way and nothing ever said a word — Tomeka Petty, five texts undelivered Sep 9-14; Cliff Tarver, both of his on Sep 18. GHL returns 201 for the send and the failure lands later, in a `status` field nothing here read. [[no-silent-failures]] WHAT IT FLAGS, on outbound messages only: • ANOTHER CLIENT'S NAME — the Atlas/Inspect-A-Deck failure, the one that costs trust • an unrendered merge field — "{{contact.first_name}}" or "{company}" reaching a homeowner • a blank name — "Hey , it's Jess" (one of the four Atlas texts did this) • a stray separator — "870-551-8424 - ." where an empty custom value left a dash hanging. Atlas's "Area Code [City], [State Abbreviation]" was empty; Inspect-A-Deck's said "Charlotte, NC". • A LEAD HAND-OFF THAT NEVER — the new-lead text to Kirk blocked by the carrier. Urgent, and ARRIVED the replacement carries the lead's NAME AND NUMBER, because an alert that only says "a text failed" costs the lead anyway. • A MESSAGE THAT NEVER — any outbound SMS left failed/undelivered/rejected, with the ARRIVED carrier's own reason, and how many in a row to that person. TWO MODES, AND THE FAST ONE IS THE POINT: --since-last every minute. Reads ONLY what changed since the last run: one conversation-list call per client, then messages only for the threads that moved. A quiet minute costs four API calls and no LLM — there is no LLM anywhere in this script. The watermark, the client names and the awaiting-status list are all cached on disk between runs. [[caching]] --hours N the full sweep, unchanged, still hourly as the backstop in case a watermark run was skipped. `--hours 720` prints the historic list of everything that never arrived, without texting anyone. Usage: python3 client_message_guard.py # report only python3 client_message_guard.py --live # ...and text Kirk anything new python3 client_message_guard.py --hours 72 # widen the window (default 24) python3 client_message_guard.py --since-last --live # the per-minute run python3 client_message_guard.py --selftest cron * * * * * /usr/bin/flock -n /tmp/cmg_fast.lock /opt/ghl/run.sh msgguardfast client_message_guard.py --since-last --live >> /opt/ghl/logs/client_message_guard.log 2>&1 # did the message ARRIVE, within the minute (Kirk 2026-09-25: "got to be every min though, speed to lead"). Atlas's new-lead hand-off to his phone was blocked by the carrier - Error 30007 - and the only thing that noticed was Kirk opening the conversation; Tomeka Petty had five texts undelivered Sep 9-14, Cliff Tarver both of his on Sep 18, and nothing ever said a word. That hand-off is the only thing that starts the call, so its replacement carries the lead's NAME AND NUMBER, and bypasses quiet hours only when it is not 3am. Every minute is affordable because --since-last reads only what MOVED: one conversation-list call per client, messages only for threads past the watermark, names cached 24h - a quiet minute is FOUR api calls and no LLM. flock stops overlap; the :57 full sweep above is the backstop. log /opt/ghl/logs/ · status /opt/ghl/status/msgguardfast.last | every 1 min, all day | just now | Sat 16:23 | 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) | 71 min ago | Sun 07:11 | OK |
catch a prospect whose FIRST text from us was a stub, not the opener firstmsg-a1 · 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 python3 first_message_guard.py --account 2 --notify # ...for a second sub-account cron 40 10,15,20 * * * /opt/ghl/run_all_sms.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-a1.last | 3x a day (10am-8pm) | 42 min ago | Sat 20:40 | OK |
catch a prospect whose FIRST text from us was a stub, not the opener firstmsg-a2 · 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 python3 first_message_guard.py --account 2 --notify # ...for a second sub-account cron 40 10,15,20 * * * /opt/ghl/run_all_sms.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-a2.last | 3x a day (10am-8pm) | 42 min ago | Sat 20:40 | OK |
catch a roofer saying he moved a homeowner, and move OUR appointment too reschedulescan · reschedule_scan.py Kirk 2026-09-24: *"scan slack (group and dm) messages from roofers and text messages in ghl as well… of them changing a homeowner's appointment time… so the reminders don't throw things off."* Then, refining it: *"Or if you're given the tim What it does, in fullreschedule_scan.py — catch a roofer saying he moved a homeowner, and move OUR appointment too. Kirk 2026-09-24: *"scan slack (group and dm) messages from roofers and text messages in ghl as well… of them changing a homeowner's appointment time… so the reminders don't throw things off."* Then, refining it: *"Or if you're given the time upfront you won't need to ask."* THE PROBLEM. A roofer rearranges a visit with the homeowner directly and tells us in passing. Our calendar still holds the old time, so the 24 hour reconfirmation, the 1 hour and the 15 minute rungs all fire against a time that is not happening — and the group thread reconfirms a slot the homeowner already knows is wrong. It makes us look like we are not paying attention, in a thread the roofer is reading. ──────────────────────────────────────────────────────────────────────────────────────────────── ⛔ WHAT THIS DELIBERATELY DOES NOT DO, AND WHY. All four were mapped live on 2026-09-24. 1. IT DOES NOT INVALIDATE. That was the original ask, and invalidating is not a mute — it is a delete. invalid_cleanup runs --live every ten minutes and pulls the contact out of every published booking workflow with DELETE /contacts/{id}/workflow/{wid}, which GHL cannot undo per run. The visit also drops out of the roofer's evening digest (tomorrow_digest.py:325), out of the client's appointment counts (b2c_tracker.py:375), and out of the group ladder (group_followup.py:449 "no upcoming appointment — ladder idle"). The homeowner would then hear NOTHING about the new time. Moving the appointment instead gives her the move notice and a fresh 24 hour reminder for free, because group_followup already handles a moved appointment correctly and already has the copy. 2. IT DOES NOT READ THE RIDGEREV MAIN LINE. Over 45 days and 145 real inbound roofer messages, seven carried move-or-reschedule language. FOUR of those were on main and NOT ONE was about a homeowner — they are roofers moving their own onboarding calls with Kirk ("Can we push it until tomorrow or later later today?"). A classifier fired MOVED on three of them. Each would have silently changed a live homeowner appointment. The main line is out of scope by design, not by omission. 3. IT DOES NOT READ SLACK. The only Slack token we hold has no history scope at all — conversations.history returns missing_scope on every channel and every DM, and search.messages needs search:read which we also lack. Reading messages requires Kirk to re-install the Slack app, and that mints a NEW token which slack_photos.py reads from the same key, so a botched re-auth silently kills the live photo cron. ⛔ AND A CORRECTION WORTH KEEPING, because the mistake was mine and it changed a decision. The only member of #warren-saber-roofing besides Kirk shows as "Jordan Doolen", so this said Warren had no Slack account and that Slack would therefore only ever cover two clients of three. Kirk, 2026-09-25: "Warren on slack as jordan doolen". It is HIS account under another display name — the handle is `saberroofingok`, matching the email his Stripe subscription is under. All three clients are reachable, so the re-auth is worth more than this file said. A display name is not an identity; the handle and the email are. Until then this says so out loud rather than reading zero messages and looking healthy. 4. IT DOES NOT GUESS WHICH HOMEOWNER. Right now all three clients have a homeowner holding TWO appointments, and the only two genuine move messages in 45 days name nobody resolvable: "I did I moved major to 1:00" — a lowercase first name that is also an ordinary roofing word, with a time carrying no day and no am/pm — and "I moved it because of the distance", a bare pronoun whose referent is in the previous message. Unresolvable means ASK, never assume. ──────────────────────────────────────────────────────────────────────────────────────────────── WHAT IT DOES read the roofer's own inbound on HIS sub-account (the 1:1 line), and the group threads he is in, read by CONVERSATION ID — /conversations/search provably never returns a group thread (verified: 79 of 79 Inspect-A-Deck conversations walked, 0 of 10 group ids returned), but GET /conversations/{id}/messages reads one fine. judge Opus, on the surrounding thread rather than one line, and it must name WHICH homeowner. match that name against THIS sub-account's upcoming appointments only — never the contact book. Three answers: exactly one, none, or cannot tell. Cannot tell never acts. act time given and complete -> move the appointment, no question asked (Kirk 2026-09-24) time missing or partial -> one text asking him for it homeowner unresolved -> page Kirk, touch nothing ⛔ DEFAULT IS DRY. Nothing is sent, nothing is moved, and every decision is printed with the exact sentence it was based on. --live is the only thing that acts. [[supervised-release]] python3 reschedule_scan.py # show every detection, change nothing python3 reschedule_scan.py --live python3 reschedule_scan.py --selftest python3 reschedule_scan.py --days 3 # how far back to read (default 2) cron 6,26,46 7-21 * * * /usr/bin/flock -n /tmp/reschedule_scan.lock /opt/ghl/run.sh reschedulescan reschedule_scan.py --live >> /opt/ghl/logs/reschedule_scan.log 2>&1 # ⛔ --live RECORDS ONLY. MOVE_APPOINTMENTS and ASK_FOR_TIME are False in the script, so this never moves an appointment or texts a roofer. What it does: a roofer relaying "she asked to reschedule/cancel" with no new time is written into group_followup state and held for HOLD_HOURS while he handles it. Kirk 2026-10-05, after Christine Saleem. Flags, not this line. log /opt/ghl/logs/ · status /opt/ghl/status/reschedulescan.last | every 20 min, 7am-9pm | 16 min ago | Sat 16:26 | OK |
copy Kirk's Canva proof decks into slide images our tracked viewer can… proofcapture · proof_capture.py Kirk 2026-10-24: "I need to track who views my social proofs ... and how far they get in the deck" — and he keeps editing the decks in Canva. Canva's embed is a cross-origin iframe, so the page around it can never see which slide a prospect What it does, in fullproof_capture.py — copy Kirk's Canva proof decks into slide images our tracked viewer can show. Kirk 2026-10-24: "I need to track who views my social proofs ... and how far they get in the deck" — and he keeps editing the decks in Canva. Canva's embed is a cross-origin iframe, so the page around it can never see which slide a prospect is on. So the decks are copied: every slide is captured from Canva's own public view link and shown in our viewer (proof_app.py), which reports each slide reached. ⛔ VIDEO SLIDES ARE COPIED TOO, SO THEY PLAY WITH SOUND THE MOMENT THE SLIDE OPENS (Kirk 2026-10-24: "play with sound autoplay"). Canva never starts a video by itself — not even in real Chrome — and a phone only allows sound when playback starts inside the viewer's own tap, which a cross-origin Canva frame can never receive. What the player plays IS reachable: pressing Play fetches two plain files from media.canva.com, the picture (.MP4) and the sound (.M4A), on links that expire within hours. So each video slide is opened, Play pressed, those two files saved and joined (ffmpeg, moov atom first so it streams) into /var/www/proof/media/<key>.mp4, and the spot the video fills on the slide recorded, so the viewer lays our own <video> exactly over the slide image. ⛔ This needs Google Chrome (`playwright install chrome`): the bundled Chromium has no H.264, so Canva's player never loads a byte of video in it and every slide would read as "no copy". ⛔ A VIDEO THAT COULD NOT BE COPIED FALLS BACK, IT DOES NOT FAIL THE DECK: that slide keeps Canva's own player (`?embed#N`, tap to play) and Kirk is told which slide. ⛔ A FAILED CAPTURE NEVER REPLACES A GOOD ONE. A new copy is written to its own folder and only becomes the live one when every slide captured; the previous copy is kept so a prospect mid-deck is never left on a dead image. Nothing changed -> nothing written. Kirk is told (once a day) when a capture COULD NOT run. python3 proof_capture.py # capture any deck that changed python3 proof_capture.py --deck warm --force python3 proof_capture.py --selftest cron 41 * * * * /usr/bin/flock -n /tmp/proof_capture.lock /opt/ghl/run.sh proofcapture proof_capture.py >> /opt/ghl/logs/proof_capture.log 2>&1 # copy Kirk's Canva proof decks into the tracked viewer when they change; LIVE 2026-10-24 log /opt/ghl/logs/ · status /opt/ghl/status/proofcapture.last | 24x a day (12am-11pm) | 35 min ago | Sat 16:41 | OK |
copy the cold-SMS list out of sub-account 1 and into sub-account 2 for… rehittopup · sms_rehit.py Kirk 2026-09-03/04: round one is out of batches, the Batch Queue has nothing un-enrolled, and "RidgeRev SMS 2" is empty. *"rehit / retext anyone who hasn't already become a client."* What it does, in fullsms_rehit.py — copy the cold-SMS list out of sub-account 1 and into sub-account 2 for round two. Kirk 2026-09-03/04: round one is out of batches, the Batch Queue has nothing un-enrolled, and "RidgeRev SMS 2" is empty. *"rehit / retext anyone who hasn't already become a client."* ⛔ KIRK'S AUDIENCE DECISION, TAKEN TWICE AND REAFFIRMED AFTER BEING SHOWN THE EXPOSURE (2026-09-04): **everyone gets re-hit except closed clients.** That deliberately includes the people who sent opt-out language to round one. He was shown the count and the TCPA reasoning and said re-hit them anyway. So this file no longer subtracts them, and — just as important — it no longer BLOCKS on the opt-out list being missing. What it does instead is COUNT them and say the number out loud on every run, because a decision that is visible every time is a decision, and one that is invisible is an accident waiting to be discovered. [[feedback-send-safety]] ⛔ THE NUMBER IS STILL BUILT AND STILL REPORTED. sms_optouts.py keeps running. If deliverability drops or a number gets flagged, "how many of these had said stop" must already be on file, not reconstructed under pressure at the worst possible moment. It REPORTS; it does not exclude. WHAT IS SUBTRACTED, AND NOTHING ELSE (Kirk's rule, verbatim: "rehit / retext anyone who hasn't already become a client"): • won clients — outputs/won_clients.csv, phone match only ([[won_clients]] explains why a name match is REPORTED and never purged) • hard bounces — outputs/hard_bounces.csv, numbers that physically cannot receive • no usable phone — nothing to text • dnd flagged in GHL — dnd / dndSettings SMS / an unsubscribe-or-DNC tag written INTO account 1. Zero contacts carry any of these today (checked live), so this subtracts nobody; it is kept as a live-signal tripwire and its count is PRINTED, so if GHL ever starts holding one it shows up as a number rather than as a silently shorter list. • Texas — the standing US-only / never-TX rule, as a last gate ([[feedback-exclude-tx]]) ⛔ AND NOTHING ELSE IS DROPPED SILENTLY. "wrong number" / "bad number" tags are NOT a STOP and NOT a bounce, so they are COUNTED AND NAMED in the report and left in the send unless Kirk asks for them out with --drop-wrong-numbers. Quietly inventing another subtraction rule is how a re-hit comes back 3,000 people short and nobody can say which three thousand. BATCH NUMBERS ARE PRESERVED, NOT REASSIGNED. Account 1's batches are stratified on four dimensions (name coverage, company type, review bucket, line type — [[feedback-batch-stratification]]) and that stratification is what makes one batch's reply rate comparable to another's. Renumbering the re-hit would throw it away for free. Batch 12 in account 2 is the same people as batch 12 in account 1, minus the exclusions. The Batch Queue's new Account column is what keeps the two from colliding — see batch_uploader.py. ⛔ IDEMPOTENT. Contacts are matched by the last ten digits of their phone against everything already in account 2, and creation goes through /contacts/upsert (which dedupes on phone), so a re-run after a network blip tops the batch up instead of doubling it. ⛔ A 201 IS NOT PROOF. Every contact created is read BACK out of account 2 and checked for its phone, its batch value and its tag before this reports success. [[feedback-automation-integrity]] python3 sms_rehit.py --selftest python3 sms_rehit.py --dry-run --limit 25 # what it WOULD do (default is dry) python3 sms_rehit.py --batch 4 --dry-run # one account-1 batch python3 sms_rehit.py --batch 4 --live # copy it for real cron 39 2 * * * /usr/bin/flock -n /tmp/sms_rehit.lock /opt/ghl/run.sh rehittopup sms_rehit.py --top-up 10 --live >> /opt/ghl/logs/batch.log 2>&1 # keeps the round-two re-hit fed without Kirk (2026-09-05, this obviously needs to be on cron... I should not be coming to you again and talking about this). Copies the next account-1 batches into account 2 IN ORDER, lowest number first, and queues each for the next free weekday, holding ten batches (~2 weeks) ahead. ⛔ IT COPIES AND QUEUES; IT NEVER SENDS — enrolment is still the 02:10 batch_uploader run above, weekday-gated and unchanged. Runs 29 minutes after that so a slow enrol cannot overlap it, and flock so a slow copy cannot overlap itself. Minute 39 is not a multiple of 5 and is unused by every explicit series on this box. Batch numbers are KEPT from account 1 so a batch reply rate stays comparable across the two rounds; batches 1-3 predate the Batch Queue sheet and are included deliberately. log /opt/ghl/logs/ · status /opt/ghl/status/rehittopup.last | daily at 2am:39 | 13h ago | Sun 02:39 | OK |
create a client's ROI/booking tracker, built from code restyle · client_roi_sheet.py WHY BUILT, NOT COPIED The service account has ZERO Drive storage, so `files.copy` of Kirk's template dies with "The user's Drive storage quota has been exceeded" — the copy would be owned by the SA. Kirk's own OAuth token (token_drive.json, What it does, in fullclient_roi_sheet.py — create a client's ROI/booking tracker, built from code. WHY BUILT, NOT COPIED The service account has ZERO Drive storage, so `files.copy` of Kirk's template dies with "The user's Drive storage quota has been exceeded" — the copy would be owned by the SA. Kirk's own OAuth token (token_drive.json, scope drive.file) CAN create files he owns, but drive.file cannot READ a file the app didn't create, so it cannot copy the template either. Building the sheet from code sidesteps both: the app owns what it makes, forever. STRUCTURE — mirrors Kirk's "Sunset Siding, Windows and Roofing Tracker" exactly: Tab "Campaign 1" : the appointment log. GHL automation writes the rows. Tab "KPI's" : all formulas, no typing. Counts dispositions out of Campaign 1 col I, sums Revenue (P) and Gross Profit (Q) FOR WON ROWS ONLY — keyed off the Won row's own label cell, never off a word typed in this file — and ROAS = Revenue / Ad Spend. Total Bookings counts UNIQUE phone numbers (Kirk 2026-08-09: a repeat booking by the same homeowner is one booking) and each percentage is a share of dispositioned rows, so it can never read >100%. Ad Spend (F1) is the ONE manual cell until the Meta token exists. Usage: python3 client_roi_sheet.py --client "Inspect-A-Deck Roofing Design & Construction" --dry-run python3 client_roi_sheet.py --client "..." --live [--location <ghl loc>] [--task <clickup id>] cron 41 6 * * 1 /usr/bin/flock -n /tmp/roi_restyle.lock /opt/ghl/run.sh restyle client_roi_sheet.py --restyle --live >> /opt/ghl/logs/client_roi_sheet.log 2>&1 # every client tracker keeps the house design, forever (Kirk 2026-09-07, "ensure this is fixed and forever more going forward... every fucking client"). Atlas and Saber sat plain for two weeks because styling was a thing a human had to remember. New trackers are now styled at birth inside create(); this is the net underneath that, so a tracker that misses it is fixed within the week instead of never. FORMAT ONLY, never a value - Atlas holds real bookings. Copies from the Inspect-A-Deck tracker, so restyling one sheet by hand restyles them all. Monday 6:41 is before the working day and clear of every explicit series on this box. log /opt/ghl/logs/ · status /opt/ghl/status/restyle.last | Mon 6am | 5d ago | Mon 06:41 | OK |
create a client's ROI/booking tracker, built from code roipanel · client_roi_sheet.py WHY BUILT, NOT COPIED The service account has ZERO Drive storage, so `files.copy` of Kirk's template dies with "The user's Drive storage quota has been exceeded" — the copy would be owned by the SA. Kirk's own OAuth token (token_drive.json, What it does, in fullclient_roi_sheet.py — create a client's ROI/booking tracker, built from code. WHY BUILT, NOT COPIED The service account has ZERO Drive storage, so `files.copy` of Kirk's template dies with "The user's Drive storage quota has been exceeded" — the copy would be owned by the SA. Kirk's own OAuth token (token_drive.json, scope drive.file) CAN create files he owns, but drive.file cannot READ a file the app didn't create, so it cannot copy the template either. Building the sheet from code sidesteps both: the app owns what it makes, forever. STRUCTURE — mirrors Kirk's "Sunset Siding, Windows and Roofing Tracker" exactly: Tab "Campaign 1" : the appointment log. GHL automation writes the rows. Tab "KPI's" : all formulas, no typing. Counts dispositions out of Campaign 1 col I, sums Revenue (P) and Gross Profit (Q) FOR WON ROWS ONLY — keyed off the Won row's own label cell, never off a word typed in this file — and ROAS = Revenue / Ad Spend. Total Bookings counts UNIQUE phone numbers (Kirk 2026-08-09: a repeat booking by the same homeowner is one booking) and each percentage is a share of dispositioned rows, so it can never read >100%. Ad Spend (F1) is the ONE manual cell until the Meta token exists. Usage: python3 client_roi_sheet.py --client "Inspect-A-Deck Roofing Design & Construction" --dry-run python3 client_roi_sheet.py --client "..." --live [--location <ghl loc>] [--task <clickup id>] cron 3 23 * * * /usr/bin/flock -n /tmp/roi_panel.lock /opt/ghl/run.sh roipanel client_roi_sheet.py --panel --live >> /opt/ghl/logs/roi_panel.log 2>&1 # rebuild the roofer-facing panel on every KPI tab: labels, formats, the red status warning, and the hidden judgement columns. FIRST in the nightly chain so everything after it writes into a panel that is current. The RidgeRev fee cell is preserved, never overwritten. log /opt/ghl/logs/ · status /opt/ghl/status/roipanel.last | daily at 11pm:03 / every 10 min, 7am-9pm | 7 min ago | Sat 16:25 | 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 * * * * * /usr/bin/flock -n /tmp/summary_refine.lock /opt/ghl/run.sh summaryrefine summary_refine.py --live >> /opt/ghl/logs/summary_refine.log 2>&1 # Kirk 2026-09-08: EVERY MINUTE, 24/7. The Homeowner Summary the roofer reads has to be rewritten and back in GHL before his notification goes out at 10 min and the group opens at 15. It ran at minutes 1,23,41 - a 22 minute worst case, wider than that whole window, so a booking landing at :02 reached him raw. The LLM rewrite is content-hash cached so a run with nothing new costs no tokens, and flock means a slow run cannot stack. log /opt/ghl/logs/ · status /opt/ghl/status/summaryrefine.last | every 1 min, all day | just now | Sat 16: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 | 15h ago | Sun 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 | 22 min ago | Sat 18:30 | OK |
does anything on a client's sub-account still belong to somebody else subaudit · subaccount_audit.py Kirk 2026-08-26: *"ensure there's an automatic check of all client sub-accounts to check for these things, and ping if any error."* What it does, in fullsubaccount_audit.py — does anything on a client's sub-account still belong to somebody else? Kirk 2026-08-26: *"ensure there's an automatic check of all client sub-accounts to check for these things, and ping if any error."* RidgeRev REUSES defunct sub-accounts, because A2P certification takes weeks and lives on the phone number: Robertson Homes became Atlas, New Foundations became Saber. That makes every field nobody explicitly overwrote a field that still belongs to the PREVIOUS occupant — and a carrier reviewer reads several of them. ⛔ "We did not set it" and "it still says the previous client" are the same sentence. Which is why this audit does not check a list of fields somebody remembered to add: it takes every OTHER company name it knows about and looks for them in the current client's account. subaccount_audit.py # check everything, print, alert on findings subaccount_audit.py --client saber # one sub-account subaccount_audit.py --quiet # cron mode subaccount_audit.py --selftest ⛔ Silence means clean. It sends nothing when every account is right. ⛔ It does NOT look at the Trust Center. Kirk 2026-08-26: *"ignore the trust center, you don't need to edit anything there."* Nothing here reads or writes it. cron 33 9 * * 1,4 /usr/bin/flock -n /tmp/subaccount_audit.lock /opt/ghl/run.sh subaudit subaccount_audit.py --quiet >> /opt/ghl/logs/subaccount_audit.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/subaudit.last | Mon, Thu 9am | 2d ago | Mon 09:33 | 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 | 12h ago | Sun 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 | Sat 16:23 | OK |
evening auto-processor for the Wavv call history callhist · process_call_history.py Pulls the call history straight from the WAVV API (wavv_api.py), then runs dm_detector (Engaged tracker's DM column), connect_rate (SMS tracker's Connect%/Close), speed_tracker, show_tracker and ltv_tracker, and texts Kirk a summary. Idempo What it does, in fullprocess_call_history.py — evening auto-processor for the Wavv call history. Pulls the call history straight from the WAVV API (wavv_api.py), then runs dm_detector (Engaged tracker's DM column), connect_rate (SMS tracker's Connect%/Close), speed_tracker, show_tracker and ltv_tracker, and texts Kirk a summary. Idempotent: skips when nothing new has landed. ⛔ THE DRIVE CSV IS NOW A FALLBACK, NOT THE SOURCE. Kirk used to export 'Outbound Call History' from Wavv Manager and drop it in the shared Drive folder, and every number below came from whatever file happened to be sitting there. The API returns the same rows (verified 646/646 against his 2026-07-16 export), so `--source auto` pulls the API first and only reaches for the newest Drive file if the API is unreachable — a Wavv outage costs nothing, and a forgotten upload no longer costs a night of data. 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 # API, fall back to Drive, 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 python3 scripts/process_call_history.py --source drive # ignore the API, use the Drive CSV python3 scripts/process_call_history.py --source api # API only, never fall back 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 | 19h ago | Mon 20:00 | OK |
evening auto-processor for the Wavv call history callhist-night · process_call_history.py Pulls the call history straight from the WAVV API (wavv_api.py), then runs dm_detector (Engaged tracker's DM column), connect_rate (SMS tracker's Connect%/Close), speed_tracker, show_tracker and ltv_tracker, and texts Kirk a summary. Idempo What it does, in fullprocess_call_history.py — evening auto-processor for the Wavv call history. Pulls the call history straight from the WAVV API (wavv_api.py), then runs dm_detector (Engaged tracker's DM column), connect_rate (SMS tracker's Connect%/Close), speed_tracker, show_tracker and ltv_tracker, and texts Kirk a summary. Idempotent: skips when nothing new has landed. ⛔ THE DRIVE CSV IS NOW A FALLBACK, NOT THE SOURCE. Kirk used to export 'Outbound Call History' from Wavv Manager and drop it in the shared Drive folder, and every number below came from whatever file happened to be sitting there. The API returns the same rows (verified 646/646 against his 2026-07-16 export), so `--source auto` pulls the API first and only reaches for the newest Drive file if the API is unreachable — a Wavv outage costs nothing, and a forgotten upload no longer costs a night of data. 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 # API, fall back to Drive, 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 python3 scripts/process_call_history.py --source drive # ignore the API, use the Drive CSV python3 scripts/process_call_history.py --source api # API only, never fall back 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 | 12h ago | Sun 03:40 | OK |
every B2C data point, as deep as the B2B side goes b2ctracker · b2c_tracker.py Kirk 2026-08-27: *"I need all this data tracked on the b2c side as a direct reflection of how deep my b2b data tracking is... every single data point tracked... data is king."* What it does, in fullb2c_tracker.py — every B2C data point, as deep as the B2B side goes. Kirk 2026-08-27: *"I need all this data tracked on the b2c side as a direct reflection of how deep my b2b data tracking is... every single data point tracked... data is king."* B2B has thirteen tracking scripts because it grew one question at a time. B2C starts with the questions already known, so it is one script writing six tabs into one sheet: Calls every call ever made or received: who, which agent, how long, answered, outcome Leads one row per homeowner: how fast we reached them, how many tries, where they ended up Funnel a row per day: leads in -> reached -> answered -> booked -> confirmed -> showed -> won Agents per agent per day: answer rate, booking rate, average length Speed lead in -> first dial -> first answer -> booked, in minutes Reasons per dial reason: attempts placed, connected, booked. Which reason earns its calls ⛔ EVERY NUMBER IS DERIVED FROM A SOURCE THAT CANNOT LIE ABOUT ITSELF. Calls come from the Nedzo ledger, which records the call whether or not anything else noticed. Appointments come from the calendar, never from a custom field — two of those were checked on 2026-08-27 and one was empty for a homeowner with an inspection that morning while the other still held a slot she had cancelled. Dispositions come from the roofer's own answer. [[derive-dont-hardcode]] ⛔ ANSWERED IS DURATION, NOT OUTCOME. Two calls on 2026-08-27 ran 45 seconds each into a loan company's IVR and were recorded as answered conversations. Duration alone overstates reach, so a call only counts as answered if it lasted AND the transcript shows a second speaker. ⛔ ONE batchUpdate, NEVER A CELL AT A TIME. Google caps writes at about sixty a minute and a loop hits it silently. [[batched-sheets-writes]] python3 b2c_tracker.py # build and print, write nothing python3 b2c_tracker.py --live cron 47 */2 * * * /usr/bin/flock -n /tmp/b2c_tracker.lock /opt/ghl/run.sh b2ctracker b2c_tracker.py --live >> /opt/ghl/logs/b2c_tracker.log 2>&1 # every B2C data point, as deep as the B2B side (Kirk 2026-08-27, "data is king"). Six tabs: every call, every lead, the daily funnel, per-agent rates, speed from lead-in to booked, and what each dial reason earns. Rebuilt whole every 2h because every number is DERIVED - an append would double-count. Minute 47 dodges every existing series. log /opt/ghl/logs/ · status /opt/ghl/status/b2ctracker.last | every 2h | 1h ago | Sat 16:47 | OK |
every client tracker has its two hidden working tabs from DAY ONE, not… clienttabs · client_tabs.py Kirk 2026-10-22, after a day of "AUTOMATION FAILED: pipeline" and "whatsnext" texts about Lazer Adjusters: "can you do number 1 for all future clients? so this doesn't happen again? automatically going in the future". What it does, in fullclient_tabs.py — every client tracker has its two hidden working tabs from DAY ONE, not from launch. Kirk 2026-10-22, after a day of "AUTOMATION FAILED: pipeline" and "whatsnext" texts about Lazer Adjusters: "can you do number 1 for all future clients? so this doesn't happen again? automatically going in the future". ⛔ THE CHICKEN AND EGG. 'RidgeRev (internal)' was created only by cycle_tracker and 'To send' only by to_send — and BOTH skip a client whose 'Cycle 1 starts' is still empty, which is every new client until the ads launch. Meanwhile client_readiness (the onboarding pipeline's `declared` step) demands the internal tab now, and whats_next reads 'To send' now. So every new client failed onboarding every hour, and paged Kirk, for the whole stretch between signing and launching. Nothing was broken; the tabs were just late. This creates both, HIDDEN and EMPTY, the moment a tracker exists. It writes no content: cycle_tracker and to_send own what goes in them and fill them as they always have once the cycle starts. Idempotent — an existing tab is never touched. Runs as an onboarding pipeline step (so a new client has them the same hour), and nightly over every tracker (so a deleted tab grows back). python3 client_tabs.py --client "Lazer Adjusters" --live python3 client_tabs.py --all --live cron 31 23 * * * /usr/bin/flock -n /tmp/client_tabs.lock /opt/ghl/run.sh clienttabs client_tabs.py --all --live >> /opt/ghl/logs/client_tabs.log 2>&1 # every tracker keeps its hidden RidgeRev (internal) + To send tabs from DAY ONE (Kirk 2026-10-22: "for all future clients... automatically"). The onboarding step makes them the same hour; this regrows a deleted one. After to_send 23:26. log /opt/ghl/logs/ · status /opt/ghl/status/clienttabs.last | daily at 11pm:31 | 16h ago | Sat 23:31 | OK |
Every client, every onboarding step, on one tab of the Tokens sheet Kirk… onboardboard · onboard_board.py Kirk 2026-10-20: *"HOW CAN I SEE FOR ALL CLIENTS ALL THE STEPS OF THE ONBOARDING PIPELINE ALREADY COMPLETED AND DONE? EASILY NO APP JUST IN ONE OF MY CURRENT SPREADSHEETS I REFERENCE?"* What it does, in fullEvery client, every onboarding step, on one tab of the Tokens sheet Kirk already opens. Kirk 2026-10-20: *"HOW CAN I SEE FOR ALL CLIENTS ALL THE STEPS OF THE ONBOARDING PIPELINE ALREADY COMPLETED AND DONE? EASILY NO APP JUST IN ONE OF MY CURRENT SPREADSHEETS I REFERENCE?"* Home is the **Tokens sheet** because that is the one he already opens during onboarding — it is where he types a client name into column E and pastes the token into column C, and its READ ME tab already holds the order. A second spreadsheet would be a second place to remember. ⛔⛔ THE STEP LIST IS DERIVED FROM `onboard_pipeline.steps_for()`, NEVER TYPED HERE. This board exists to show that clients are missing steps, and a hand-typed copy of the step names would drift from the pipeline in exactly the way it is built to reveal — reporting a clean board for a client missing a step this file had never heard of. [[derive-dont-hardcode]] ⛔ THE COLUMN THAT MATTERS IS THE LAST ONE: "Never ran". A client is marked `done` by `onboard_pipeline.main()` (:640) and then skipped for ever (:488). Steps added to the pipeline AFTER that date never run for them, and nothing anywhere says so. Measured 2026-10-20: Atlas and Saber each carry 8 steps and `done`, while the pipeline now defines 11 — so `brevo_api`, `voice_agents` and `declared` have never run for either of them, and `declared` (client_readiness, "the step that makes all of it stick") has never run for ANY client. A board that showed only ticks would have called both of them finished. cron 35 8-20 * * * /usr/bin/flock -n /tmp/onboard_board.lock /opt/ghl/run.sh onboardboard onboard_board.py --live >> /opt/ghl/logs/onboard_board.log 2>&1 # the Onboarding tab of the Tokens sheet: every client, every pipeline step, and the steps a client marked done never ran. Kirk 2026-10-20 "how can i see for all clients all the steps already completed, no app, in one of my current spreadsheets". :35 is 15 min after the pipeline at :20 so it reflects that run, and is OUTSIDE every cycle_chain tick (6-56/5, 91s avg) so it never competes for the 60-reads-a-minute Sheets cap. log /opt/ghl/logs/ · status /opt/ghl/status/onboardboard.last | 13x a day (8am-8pm) | 47 min ago | Sat 16:35 | OK |
every cold-SMS contact carries its OWN timezone, in every sub-account tzheal-a1 · backfill_timezone.py WHY (Kirk 2026-09-07: "fix the fact that the leads dont have timezones as they do in sub account sms 1 ... make it automatic that this happens in future transitions"): A contact with no timezone falls back to the SUB-ACCOUNT's timezone, and What it does, in fullbackfill_timezone.py — every cold-SMS contact carries its OWN timezone, in every sub-account. WHY (Kirk 2026-09-07: "fix the fact that the leads dont have timezones as they do in sub account sms 1 ... make it automatic that this happens in future transitions"): A contact with no timezone falls back to the SUB-ACCOUNT's timezone, and call_retry says exactly what that costs: "8pm in Chicago is 6pm in California, and it rang." Send and call windows are computed against the wrong clock, which is a quiet-hours problem, not a cosmetic one. ⛔ AND IT IS NOT A COPY BUG. sms_rehit's payload_for carries `timezone` correctly — it copies NOTHING because there is nothing to copy. ghl_upsert GAINED the timezone field between the June and August batches, so account 1's older contacts never had one: roofing-06-24-2026 -> 0 of 100 have a timezone roofing-08-12-2026 -> 100 of 100 have one The re-hit copies account 1's OLDEST batches first, so round two inherited the hole and concentrated it. Measured 2026-09-07: account 1 3,957 of 22,039 blank (18%), account 2 3,497 of 6,722 (52%). ⛔ So account 1 has the same disease, milder — fixing only account 2 would have left 4,000 of Kirk's own round-one leads on the wrong clock. WHERE THE VALUE COMES FROM, in order, and never a guess: 1. the SMS master's `timezone` for that phone (EST/CST/PST/MST -> IANA) — the same source ghl_upsert uses, so a backfilled contact is identical to a freshly-upserted one. 2. failing that, the contact's own STATE through the pipeline's own state->zone map. The master's timezone is itself derived that way (update_call_masters), so this is the same fact, not a second opinion. 3. neither -> COUNTED AND NAMED, never filled. A wrong timezone is worse than none: none is visibly missing, wrong is invisibly wrong. ⛔ AND AN EMPTY SHELL IS NOT A FAULT. The four account-1 contacts that could never be sourced turned out not to be roofing prospects at all — they carry `wavv-no-answer` / `wavv-none` or nothing, no company, no city, no state, and one has no phone. They are dialer artifacts. Reporting them under a ⛔ every night trains the eye to skip the line that will one day name a real lead, so a contact with no cold-SMS tag is counted separately and quietly. An unsourceable ROOFING lead is still a ⛔. Idempotent and safe to re-run — it only ever writes a contact whose timezone is BLANK, so it can sit on cron and self-heal whatever any future upsert, copy or new sub-account misses. That is the "automatic in future transitions" part: it is not tied to the re-hit, it watches the accounts. Usage: python3 backfill_timezone.py --account 2 --dry-run # count, write nothing python3 backfill_timezone.py --account 2 --limit 25 # supervised small run python3 backfill_timezone.py --account 2 # fix every blank it can source (on cron via run_all_sms.sh, so a new sub-account is covered with no edit) cron 47 23 * * * /opt/ghl/run_all_sms.sh tzheal backfill_timezone.py >> /opt/ghl/logs/tzheal.log 2>&1 # every cold-SMS contact carries its OWN timezone, in EVERY sub-account (Kirk 2026-09-07). Without one GHL schedules against the sub-account clock - 8pm in Chicago is 6pm in California. Only ever fills a BLANK (plus corrects HI/AK, which the four-bucket map files as Pacific), so it is idempotent and self-heals whatever any upsert, copy or NEW sub-account misses. Minute 47 is unused at hour 23 and clear of lp-selfheal at :00. log /opt/ghl/logs/ · status /opt/ghl/status/tzheal-a1.last | daily at 11pm:47 | 16h ago | Sat 23:47 | OK |
every cold-SMS contact carries its OWN timezone, in every sub-account tzheal-a2 · backfill_timezone.py WHY (Kirk 2026-09-07: "fix the fact that the leads dont have timezones as they do in sub account sms 1 ... make it automatic that this happens in future transitions"): A contact with no timezone falls back to the SUB-ACCOUNT's timezone, and What it does, in fullbackfill_timezone.py — every cold-SMS contact carries its OWN timezone, in every sub-account. WHY (Kirk 2026-09-07: "fix the fact that the leads dont have timezones as they do in sub account sms 1 ... make it automatic that this happens in future transitions"): A contact with no timezone falls back to the SUB-ACCOUNT's timezone, and call_retry says exactly what that costs: "8pm in Chicago is 6pm in California, and it rang." Send and call windows are computed against the wrong clock, which is a quiet-hours problem, not a cosmetic one. ⛔ AND IT IS NOT A COPY BUG. sms_rehit's payload_for carries `timezone` correctly — it copies NOTHING because there is nothing to copy. ghl_upsert GAINED the timezone field between the June and August batches, so account 1's older contacts never had one: roofing-06-24-2026 -> 0 of 100 have a timezone roofing-08-12-2026 -> 100 of 100 have one The re-hit copies account 1's OLDEST batches first, so round two inherited the hole and concentrated it. Measured 2026-09-07: account 1 3,957 of 22,039 blank (18%), account 2 3,497 of 6,722 (52%). ⛔ So account 1 has the same disease, milder — fixing only account 2 would have left 4,000 of Kirk's own round-one leads on the wrong clock. WHERE THE VALUE COMES FROM, in order, and never a guess: 1. the SMS master's `timezone` for that phone (EST/CST/PST/MST -> IANA) — the same source ghl_upsert uses, so a backfilled contact is identical to a freshly-upserted one. 2. failing that, the contact's own STATE through the pipeline's own state->zone map. The master's timezone is itself derived that way (update_call_masters), so this is the same fact, not a second opinion. 3. neither -> COUNTED AND NAMED, never filled. A wrong timezone is worse than none: none is visibly missing, wrong is invisibly wrong. ⛔ AND AN EMPTY SHELL IS NOT A FAULT. The four account-1 contacts that could never be sourced turned out not to be roofing prospects at all — they carry `wavv-no-answer` / `wavv-none` or nothing, no company, no city, no state, and one has no phone. They are dialer artifacts. Reporting them under a ⛔ every night trains the eye to skip the line that will one day name a real lead, so a contact with no cold-SMS tag is counted separately and quietly. An unsourceable ROOFING lead is still a ⛔. Idempotent and safe to re-run — it only ever writes a contact whose timezone is BLANK, so it can sit on cron and self-heal whatever any future upsert, copy or new sub-account misses. That is the "automatic in future transitions" part: it is not tied to the re-hit, it watches the accounts. Usage: python3 backfill_timezone.py --account 2 --dry-run # count, write nothing python3 backfill_timezone.py --account 2 --limit 25 # supervised small run python3 backfill_timezone.py --account 2 # fix every blank it can source (on cron via run_all_sms.sh, so a new sub-account is covered with no edit) cron 47 23 * * * /opt/ghl/run_all_sms.sh tzheal backfill_timezone.py >> /opt/ghl/logs/tzheal.log 2>&1 # every cold-SMS contact carries its OWN timezone, in EVERY sub-account (Kirk 2026-09-07). Without one GHL schedules against the sub-account clock - 8pm in Chicago is 6pm in California. Only ever fills a BLANK (plus corrects HI/AK, which the four-bucket map files as Pacific), so it is idempotent and self-heals whatever any upsert, copy or NEW sub-account misses. Minute 47 is unused at hour 23 and clear of lp-selfheal at :00. log /opt/ghl/logs/ · status /opt/ghl/status/tzheal-a2.last | daily at 11pm:47 | 16h ago | Sat 23:47 | OK |
every upcoming appointment, and whether a reminder ladder is actually apptcoverage · appt_coverage.py covering it. What it does, in fullappt_coverage.py — every upcoming appointment, and whether a reminder ladder is actually covering it. Kirk 2026-09-28, after Gail Wachob no-showed with no 24-hour, 1-hour or 15-minute reminder: *"what else needed here? for all future and current homeowners?"* ⛔ THIS IS THE ONE CHECK THAT DOES NOT CARE WHY. Two homeowners lost their reminders on the same day for two completely unrelated reasons: · Gail Wachob — her ladder was HELD because the roofer confirmed the booked time, and a hold had no expiry. · Ruby DeGraw — her appointment was on a `(S.B)` self-booked calendar, which `group_intro` excluded outright, so she had no group thread at all and was invisible to the ladder. Both were fixed. Neither fix would have caught the other, and the next one will be a third thing. So this asks the only question that matters from the homeowner's side — **is anything going to remind this person?** — and answers it per appointment, from the calendar, without caring how the machinery is supposed to work. ⛔ IT READS THE CALENDAR, NOT THE THREAD LIST. Checking coverage by walking known threads can only ever find problems among homeowners the system already knows about, which is precisely the set that excludes the Rubys. The calendar is the source of truth for who has an appointment. ⛔ SELF-BOOKED CALENDARS ARE INCLUDED. Excluding them is what hid Ruby for ten days. ⛔ IT MOVES NO DATA AND WRITES NOTHING — but under `--live`, which is how cron runs it, IT TEXTS KIRK. The docstring used to say "sends nothing to anybody", which is how the next person concludes `--live` is safe to try from a terminal and quietly pages him. Nothing reaches a client or a homeowner from here, ever. [[prove-it-runs-live]] cron 41 7-20/3 * * * /usr/bin/flock -n /tmp/appt_coverage.lock /opt/ghl/run.sh apptcoverage appt_coverage.py --live >> /opt/ghl/logs/appt_coverage.log 2>&1 # every upcoming appointment vs the reminder ladder AND the tracker sheet (Kirk 2026-09-28: "please ensure this doesnt happen again... for self booked"). Ruby DeGraw sat TEN DAYS self-booked and invisible, so every 3h from 07:41 rather than twice a day: a booking made this morning is surfaced this morning. ⛔ Reads the CALENDAR, not the thread list. Minute 41 dodges every existing series. log /opt/ghl/logs/ · status /opt/ghl/status/apptcoverage.last | 5x a day (7am-7pm) | 2h ago | Sat 16:41 | OK |
every won roof becomes a chance at a second appointment referralengine · referral_engine.py Kirk 2026-10-23/24 (approved plan, ~/.claude/plans/yes-build-it-but-dapper-ullman.md, Part A): What it does, in fullreferral_engine.py — every won roof becomes a chance at a second appointment. Kirk 2026-10-23/24 (approved plan, ~/.claude/plans/yes-build-it-but-dapper-ullman.md, Part A): WON on the tracker ─► ask the homeowner for a referral (their timing, not the cycle's) ─► they fill the page (referral_page.py) — SUBMITTING IS THE FRIEND'S PERMISSION ─► they tap one button: a group text of them + the friend + OUR texting number ─► no intro after 24h: ONE reminder ─► still none after 24h: we open the group ourselves (last resort, OPEN_RECOVERY_GROUP) ─► otherwise "Referral pending", stop INTRODUCED + qualified ─► booking gate: cycle open + room left + client taking appointments + ads spending ─► "Call now" (a PERSON calls, no AI) — else waits for the next cycle, expires after 7 days unless somebody calls to re-confirm. Tracked on the roofer's own sheet: a VISIBLE `Referrals` tab (Kirk 2026-10-24: "keep it open"), and a hidden `Source` column (X) on Campaign 1. ⛔⛔ EVERY HOMEOWNER SEND IS OFF IN CODE (homeowner-sends-are-off-by-default). `send_text()` is the ONLY function that posts a 1:1 text and `open_recovery_group()` the only one that opens a group; each checks its own switch FIRST, returns False when silenced and prints what it would have sent. A client also needs "ON" typed by Kirk on the private control sheet. Neither alone sends anything. ⛔ ONE SOURCE OF TRUTH FOR THE CYCLE. Nothing about a cycle is stored here. Window, target, delivered and the next window come from cycle_notice.state_of / cycle_tracker.cycle_windows every run — the same answer billing uses — so a cycle that ends early, runs late or is pushed a week is followed the same day. The only cycle facts added are four cells Kirk types on the private control sheet: taking appointments, paused because, next cycle approved to start, ask customers won since. python3 referral_engine.py # dry run: print everything, write nothing python3 referral_engine.py --live # write sheets + ledger; texts only where switched on python3 referral_engine.py --selftest cron 2-52/10 8-21 * * * /usr/bin/flock -n /tmp/referral_engine.lock /opt/ghl/run.sh referralengine referral_engine.py --live >> /opt/ghl/logs/referral_engine.log 2>&1 # won job -> referral ask/page/reminder/gate + Referrals tab + hidden Source col X; every text OFF in code; LIVE 2026-10-24 log /opt/ghl/logs/ · status /opt/ghl/status/referralengine.last | every 10 min, 8am-9pm | 9 min ago | Sat 16:32 | OK |
Fill the Client Billing & Churn sheet from Stripe, so the money columns… billing · billing_tracker.py Kirk 2026-08-30: "I need this sheet updated properly for 2026... you can see everything... you should be able to cron all these... and create something 100% accurate." What it does, in fullFill the Client Billing & Churn sheet from Stripe, so the money columns are never typed by hand. Kirk 2026-08-30: "I need this sheet updated properly for 2026... you can see everything... you should be able to cron all these... and create something 100% accurate." ⛔ THE ONE THING THAT WOULD HAVE MADE THIS SHEET WRONG: a Stripe subscription that says `active` is NOT a paying client. KBM, AOS and Bill Highstreet all carry `pause_collection: {behavior: keep_as_draft}`. Stripe still calls two of them `active`, still advances their next-bill date, and still cuts an invoice every month — the invoice just stays a DRAFT and is never charged. Across 2026 that is 24 draft invoices worth about $59,000 that no one ever paid. Building this on subscription status would have reported six clients and ~$5,000 MRR against a reality of three clients and $3,000 collected all year. So EVERY number here comes from a PAID invoice with money actually on it (`amount_paid > 0`). A client, for this sheet, is someone who paid at least once in the year. That definition is also what makes churn work: a client who leaves mid-year keeps their row and their history, because the row is built from what they paid, not from whether they are still in anybody's active list. Decisions Kirk made on 2026-08-30, all four of them the honest-to-cash option: · who counts — only who actually pays. The paused accounts are not on the sheet at all. · month math — cash actually COLLECTED in those calendar dates, so it ties to the bank. The roofers bill every 4 WEEKS, so there are 13 periods a year: some calendar months genuinely carry two charges and some carry one. That is not an error. · churn — a client is gone as of their LAST PAID invoice. A dormant subscription that collects nothing never inflates retention. · layout — his columns, untouched. This fills what Stripe can prove and never writes Source or the reason somebody left. Those are his. [[client-tracker-sheets]] Columns are found BY LABEL, never by position, because that sheet's header row sits under two rows of merged month banners and a row of totals. [[client-tracker-sheets]] python3 billing_tracker.py # dry: print every cell it would write python3 billing_tracker.py --live # write them python3 billing_tracker.py --year 2026 # a different year's sheet ⛔ IT NEVER OVERWRITES A FORMULA. The sheet reads back with valueRenderOption=FORMULA precisely so this can see one and refuse. That is not theoretical: `Pay Date` (column I) is not data at all, it is `=C5` mirroring Sign, and on the KPIs tab Churn, Net Clients and Surplus are formulas off the counts, with the YTD rows averaging and summing them. A writer that treated those as empty cells would have replaced live formulas with dead literals on its first run. ⛔ A ROW'S HAND-TYPED CELLS BELONG TO WHOEVER IS IN IT. Source and Launch are Kirk's and are never written — so a row can never be quietly re-pointed at a different client, or the new occupant inherits the old one's Source and Launch. A known client is updated in place, a new one is appended, and a row holding somebody who is not a client this year is cleared outright. Every run snapshots the sheet to ~/billing_tracker_<year>_before_<stamp>.json first, writes in ONE batchUpdate, and then reads the sheet back to prove the clients are actually there. cron 31 7 * * * /usr/bin/flock -n /tmp/billing_tracker.lock /opt/ghl/run.sh billing billing_tracker.py --live >> /opt/ghl/logs/billing_tracker.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/billing.last | daily at 7am:31 | 8h ago | Sun 07:31 | 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 | 6h ago | Sun 09:55 | OK |
Get back a homeowner who cancelled in the group thread — the opener, then… recovery · recovery.py python3 recovery.py # report only — who is owed, and the draft python3 recovery.py --live # actually send it python3 recovery.py --selftest What it does, in fullGet back a homeowner who cancelled in the group thread — the opener, then the same ladder. python3 recovery.py # report only — who is owed, and the draft python3 recovery.py --live # actually send it python3 recovery.py --selftest ⛔⛔ NOTHING ANSWERED THESE PEOPLE AT ALL, AND BOTH HALVES OF THAT WERE DELIBERATE. A homeowner who cancels in the group thread got an alert to Kirk and nothing else. reschedule_bot READS group threads (it fetches them first, on purpose, because /conversations/search never returns one) and then throws them away before classification — "the roofer is in that thread. He reads it himself" — and its own CANCEL branch closed a cancellation out with one goodbye SMS and `{"stage": "closed"}`, thirty lines above the RESCHEDULE branch that arms the chase. The chase machinery existed and the cancel branch deliberately did not use it. ⛔ THE 1:1 THREAD, NEVER THE GROUP. She cancelled in the group and the roofer is sitting in it. Kirk's rule: "Never write, propose, or 'clean up' in a client group thread. Not even one line." Only the 24-hour confirmation and the move notice may ever post there. So the attempt goes to her own thread, where a reply reaches us and not him. ⛔⛔ AND IT IS NO LONGER ONE TEXT AND A CLOSED DOOR. Kirk 2026-10-04: *"don't be like 'so you wanted to cancel, reach out when ready' — fuck that. Treat them like they want to reschedule, just run the reschedule flow"* and *"the same reschedule flow you run to get reschedules for no shows — just adjust for cancels to get back on calendar."* So this job sends the OPENER and then arms reschedule_bot's ladder: ten offers over sixty days, with every guard that ladder already has. See OPENERS, CHECKPOINT_OFFERS and WAIT_HOURS for the three decisions that changed and what each one replaced. ⛔ WHAT IT COULD ACTUALLY SEND, FOR ITS WHOLE LIFE BEFORE THAT: one message, to one person. `cal = RB.calendar_for(...) if hasattr(RB, "calendar_for")` named a function that has never existed — it is resched_calendar — so slots was always empty, draft() always took its no-slots branch, and that branch said "just reply here and we'll find a time that suits you". The hasattr guard turned a one-word typo into a permanent, quiet "the calendar has no openings", and the selftest only ever exercised the branch the typo forced. [[reference_a_check_that_cannot_fail]] cron 51 9 * * * /usr/bin/flock -n /tmp/recovery.lock /opt/ghl/run.sh recovery recovery.py --live >> /opt/ghl/logs/recovery.log 2>&1 # ⛔ IT IS --live AND THE SEND IS GATED IN CODE, not here: reschedule_bot.CHASE_CANCELS is False, so this DRAFTS and prints what would go. Flip that constant, not this line. Opener in her 1:1, never the group; then reschedule_bot's ladder. Reasoning: see recovery.py. log /opt/ghl/logs/ · status /opt/ghl/status/recovery.last | daily at 9am:51 | 6h ago | Sun 09:51 | OK |
GHL emails us every A2P campaign status change — read it and fill the… a2pemail · a2p_email_watch.py Kirk 2026-10-20: *"for a2p i give you access to my email and when i get an email saying a2p campaign approved you add to the sheet for that account."* What it does, in fullGHL emails us every A2P campaign status change — read it and fill the Tokens sheet. Kirk 2026-10-20: *"for a2p i give you access to my email and when i get an email saying a2p campaign approved you add to the sheet for that account."* ⛔⛔ THIS IS THE ONLY WAY A2P STATUS CAN BE READ, AND IT IS NOT THE API. Verified three times (2026-08-25 twice, 2026-10-20 again): every A2P path 404s on both the agency and the sub-account token, on both hosts, and a CERTIFIED location diffed against a blank one differs only in firstName/lastName. `bundleSid` and `addressSid` are null on every number in the fleet. There is nothing to read. See [[project_a2p_pool]]. But GHL *emails* `webb.delroy33@gmail.com` on every change, and the body carries the LOCATION ID: Subject: A2P Campaign Registration Status Update - NEW CLIENT POOL 03 From: noreply@donotreply.leadconnectorhq.com Location Name: NEW CLIENT POOL 03 LocationID: oLIu0zhcEuPQcgFGgPwq Your A2P Campaign has been submitted for review. ⛔ The ID is the fact; the Location Name is not. A pool account is renamed the moment a client claims it, so an email sent before the rename names an account that no longer exists under that name — and two pool accounts have carried the name "Robertson Homes Remodeling Services, LLC" at once. Match on LocationID only. [[derive-dont-hardcode]] ⛔ SUBMITTED IS NOT APPROVED. The first real email we saw says "has been submitted for review", which means the clock has STARTED, not finished. Only an explicitly approved status writes YES. Anything whose wording this file does not recognise is reported as unknown and written nowhere — a status we cannot read must never become a green light. [[judge-dont-match]] cron 40 9 * * * /usr/bin/flock -n /tmp/a2p_email_watch.lock /opt/ghl/run.sh a2pemail a2p_email_watch.py --live >> /opt/ghl/logs/a2p_email_watch.log 2>&1 # A2P status cannot be read by API (verified 3x) but GHL EMAILS it, and the body carries the LocationID. Reads gmail.readonly, writes YES into column F of the Tokens sheet, and keeps subaccount_pool.json in step so a2p_buffer stops counting 0 approved. Daily is plenty - A2P takes weeks. :40 is outside every cycle_chain tick (6-56/5) so it never competes for the 60-reads-a-minute Sheets cap. log /opt/ghl/logs/ · status /opt/ghl/status/a2pemail.last | daily at 9am:40 | 6h ago | Sun 09:40 | OK |
GHL says it sent them sendivodelivery · sendivo_delivery_watch.py Kirk 2026-10-12: *"fix to alert me if any sendivo payment errors ever hsppen."* What it does, in fullsendivo_delivery_watch.py — GHL says it sent them. Did Sendivo actually send them? Kirk 2026-10-12: *"fix to alert me if any sendivo payment errors ever hsppen."* ⛔ WHAT THIS IS ACTUALLY WATCHING, AND WHY IT IS NOT A PAYMENT CHECK. Sendivo's API has no /account, /billing, /wallet or /status endpoint — all four 404. A locked account answers GET /sms/logs with a perfectly ordinary **200 and an empty list**. There is no error to catch, which is precisely how this one hid. WHAT HAPPENED. Sendivo locked the account after repeated payment failures and suspended messaging. GHL carried on handing it ~530 contacts a day through the `Sendivo For SMS` conversation provider and marking every one `sent` — because in GHL, `sent` means HANDED TO THE PROVIDER, not delivered. Sendivo logged none of it. 1,041 roofers across 9 and 12 October were tagged `rehit-r2` / `sms-intro-r2-v4`, moved through the sequence, and received nothing. Nothing retries them. The only visible signal was Kirk noticing his reply rate had died. SO THE DETECTOR IS THE DIVERGENCE, NOT THE CAUSE. If GHL says it messaged 500 people today and Sendivo logged 3, something has stopped delivery — a payment lock, a suspended A2P campaign, a revoked key, a provider misconfiguration. This fires on all of them without needing to know which, and it would have caught this on the morning of 9 October instead of the 12th. ⛔ IT NEVER REPORTS HEALTH IT COULD NOT MEASURE. If Sendivo is unreachable, or GHL is, it says COULD NOT CHECK and pages on that — it does not read an unreachable Sendivo as "zero sent" and it does not read an unreachable GHL as "nothing to compare". [[no-silent-failures]] ⛔ IT DOES NOT GO QUIET WHILE BROKEN. queue_runway taught this the expensive way: it said SHORT once, then suppressed itself for 4 days because "it has not got worse" — but at zero there is no worse, so the one state that should shout loudest was the one it muted. A divergence here repages every day it persists. [[alert-quality]] python3 sendivo_delivery_watch.py --selftest python3 sendivo_delivery_watch.py # dry: print the comparison, page nobody python3 sendivo_delivery_watch.py --live # page Kirk if they diverge cron 27 13,19 * * * /usr/bin/flock -n /tmp/sendivo_delivery.lock /opt/ghl/run.sh sendivodelivery sendivo_delivery_watch.py --live >> /opt/ghl/logs/sendivo_delivery.log 2>&1 # did Sendivo actually SEND what GHL says it sent? Catches a billing lock / suspension, which answers /sms/logs with a normal empty 200 (Kirk 2026-10-12). Sends run ~09-18 CDT; 13:27 mid-run, 19:27 after. See the script docstring. log /opt/ghl/logs/ · status /opt/ghl/status/sendivodelivery.last | 2x a day (1pm-7pm) | 2h ago | Sat 19:27 | OK |
GHL-NATIVE backfill of outbound_caller_label + outbound_area_code lp-selfheal-a1 · 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 python3 scripts/backfill_local_presence_ghl.py --account 2 # ...in a second sub-account cron 0 23 * * * /opt/ghl/run_all_sms.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-a1.last | daily at 11pm:00 | 17h ago | Sat 23:00 | OK |
GHL-NATIVE backfill of outbound_caller_label + outbound_area_code lp-selfheal-a2 · 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 python3 scripts/backfill_local_presence_ghl.py --account 2 # ...in a second sub-account cron 0 23 * * * /opt/ghl/run_all_sms.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-a2.last | daily at 11pm:00 | 17h ago | Sat 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 | 12h ago | Sun 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 | Sat 16:34 | OK |
INSTANT reply triage for the RidgeRev SMS sub (Kirk 2026-07-19: responder-a1 · 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[_aN].json, ONE PER SUB-ACCOUNT (seeded at deploy — no backlog blasting). Which accounts run is derived from .env, not typed here. cron * 7-21 * * * /opt/ghl/run_all_sms.sh responder reply_responder.py >> /opt/ghl/logs/responder.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/responder-a1.last | every 1 min, 7am-9pm | 2 min ago | Sat 16:23 | OK |
INSTANT reply triage for the RidgeRev SMS sub (Kirk 2026-07-19: responder-a2 · 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[_aN].json, ONE PER SUB-ACCOUNT (seeded at deploy — no backlog blasting). Which accounts run is derived from .env, not typed here. cron * 7-21 * * * /opt/ghl/run_all_sms.sh responder reply_responder.py >> /opt/ghl/logs/responder.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/responder-a2.last | every 1 min, 7am-9pm | just now | Sat 16:23 | OK |
INSTANT reply triage for the RidgeRev SMS sub (Kirk 2026-07-19: holdingtext-a1 · 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[_aN].json, ONE PER SUB-ACCOUNT (seeded at deploy — no backlog blasting). Which accounts run is derived from .env, not typed here. cron * 22-23,0-6 * * * /opt/ghl/run_all_sms.sh holdingtext reply_responder.py --offhours >> /opt/ghl/logs/holdingtext.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/holdingtext-a1.last | every 1 min, 12am-11pm | 9h ago | Sat 22:00 | OK |
INSTANT reply triage for the RidgeRev SMS sub (Kirk 2026-07-19: holdingtext-a2 · 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[_aN].json, ONE PER SUB-ACCOUNT (seeded at deploy — no backlog blasting). Which accounts run is derived from .env, not typed here. cron * 22-23,0-6 * * * /opt/ghl/run_all_sms.sh holdingtext reply_responder.py --offhours >> /opt/ghl/logs/holdingtext.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/holdingtext-a2.last | every 1 min, 12am-11pm | 9h ago | Sat 22:00 | OK |
is every client's sub-account actually built the same subparity · subaccount_parity.py Kirk 2026-08-29: *"main automations and forms were missing... this was scary. Create a cron that checks across the accounts if there is any discrepancies with automations missing, forms missing, stuff like that... you have to have some watc What it does, in fullsubaccount_parity.py — is every client's sub-account actually built the same? Kirk 2026-08-29: *"main automations and forms were missing... this was scary. Create a cron that checks across the accounts if there is any discrepancies with automations missing, forms missing, stuff like that... you have to have some watch to check on that."* He found it by hand: forms and workflows he had to re-add to a live client's account. Nothing in the system was looking, because nothing was ever built to look — the accounts are created from a snapshot once and then never compared again. A snapshot that half-loads is silent, and the first symptom is a client whose lead form does not exist. ⛔ THE FAILURE IS NOT ONLY "MISSING". It is also "there, but in DRAFT". Every one of these accounts carries 76-110 draft workflows against ~31 published ones, and a draft does nothing and shows up nowhere you would look for a working automation. On 2026-08-29 the ONLY functional gap across all three accounts was exactly that: `do not call` was published on Inspect-A-Deck and sitting in draft on Saber and Atlas. Comparing names alone would have called that a clean pass. ⛔ AND THE BASELINE IS THE OTHER CLIENTS, NOT A HARDCODED LIST. A reference account goes stale the moment somebody improves a real one, and then the watch reports the improvement as a fault. So the rule is consensus: if an asset exists in every OTHER active account and not in this one, that is a gap. One client is never wrong on its own — it takes the rest of them to say so. [[derive-dont-hardcode]] [[no-silent-failures]] [[sweep-the-principle]] ⛔ AN ENDPOINT THAT DOES NOT ANSWER IS "COULD NOT CHECK", NEVER "NOTHING THERE". Saber's /users returns 401 on its client token. Reading that as "this client has no staff" would file a real account as empty and, worse, would make the report look clean. python3 subaccount_parity.py # print python3 subaccount_parity.py --live # print and page Kirk if anything is off cron 17 8 * * * /usr/bin/flock -n /tmp/subaccount_parity.lock /opt/ghl/run.sh subparity subaccount_parity.py --live >> /opt/ghl/logs/subaccount_parity.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/subparity.last | daily at 8am:17 | 8h ago | Sun 08:17 | OK |
Is every real meeting ACTUALLY on Kirk's Plan calendar right now planneraudit · planner_audit.py ⛔ WHY THIS EXISTS SEPARATELY FROM THE PLANNER. week_planner already checks its own work (`_dropped_meetings`) — and that check silently became a no-op for six weeks when `fill_gaps()` made the planned day gapless, because it only asked whet What it does, in fullIs every real meeting ACTUALLY on Kirk's Plan calendar right now? Pages him if not. ⛔ WHY THIS EXISTS SEPARATELY FROM THE PLANNER. week_planner already checks its own work (`_dropped_meetings`) — and that check silently became a no-op for six weeks when `fill_gaps()` made the planned day gapless, because it only asked whether SOME block covered a meeting's midnight. A client LAUNCH call sat invisible the whole time and nothing said a word. A check that runs inside the thing it checks, on the same in-memory data, can go blind exactly like that. So this one reads the FINISHED ARTEFACT: the events really on the Plan calendar, compared against the real meetings really on his calendars and in GHL. It is the check that found that bug. Two invariants: 1. every real meeting/demo has a matching block on the Plan calendar 2. no bookable "Reserved Sales Call Slot" sits on top of a real meeting (a prospect could double-book it) A read that fails is reported as COULD NOT CHECK and exits non-zero — never as "all good". [[no-silent-failures]] Kirk 2026-09-07. python3 planner_audit.py # report only python3 planner_audit.py --live # report AND text Kirk if anything is wrong cron 29 7,18 * * * /opt/ghl/run.sh planneraudit planner_audit.py --live >> /opt/ghl/logs/planner.log 2>&1 # is every real meeting ACTUALLY on the Plan calendar (Kirk 2026-09-07, "ensure this never happens again"). Reads the FINISHED calendar and compares it against his real meetings + GHL - week_planner already checks its own work and that check went silently no-op for six weeks when fill_gaps made the day gapless, hiding a client launch call. Also flags a bookable sales slot sitting on a real meeting (a prospect could book over it). An unreadable day prints COULD NOT CHECK and exits 1, never "all good". Minute 29 is odd and not divisible by 3 (dodges */2 planner-watch and */3 zoomwatch) and is unused by every explicit series; hours 7/18 avoid the 10,22 call_hours_watch that shares the minute. log /opt/ghl/logs/ · status /opt/ghl/status/planneraudit.last | 2x a day (7am-6pm) | 8h ago | Sat 18:29 | OK |
is this client actually wired up, measured against what we DECLARED clientready · client_readiness.py Kirk 2026-09-27: *"ensure all new clients added to this automatically as well."* ⚠ CLIENT SETUP — 2 client(s) are missing something the manifest says they must have: HDrdrryX — register them, o What it does, in fullclient_readiness.py — is this client actually wired up, measured against what we DECLARED? Kirk 2026-09-27: *"ensure all new clients added to this automatically as well."* ⛔ subaccount_parity CANNOT ANSWER THIS, AND DELIBERATELY SO. Its baseline is consensus — "if an asset exists in every OTHER active account and not in this one, that is a gap" — which is the right rule for GHL assets, because a hardcoded reference goes stale the moment somebody improves a real account. But it has two blind spots by construction: 1. something missing from EVERY client looks like agreement. All three were missing the disposition custom fields from the manifest and consensus called it clean. 2. it only sees GHL assets. The Counting flag is a SHEET CELL, the Submissions tab is a SHEET TAB, the date format is a SHEET FORMAT, and the subscription is in Stripe. None of them is a workflow or a form, so none of them is ever compared. So this checks each client against `client_config_manifest.json` — what we said is required — rather than against the other clients. The two watches answer different questions and both are needed. ⛔ A DECLARATION NOBODY CHECKS IS A COMMENT. Writing the requirement into the manifest was the easy half; this is the half that makes it true. Found 2026-09-27 by looking for the reader and finding there wasn't one. ⛔ AND IT REPORTS, IT NEVER REPAIRS. The repairs live in the jobs that own them — tracker_dates paints the columns, submissions_log creates its tab. A watch that fixes things hides how often it has to. python3 client_readiness.py python3 client_readiness.py --live # page Kirk when something is missing python3 client_readiness.py --selftest cron 22 8 * * * /usr/bin/flock -n /tmp/client_readiness.lock /opt/ghl/run.sh clientready client_readiness.py --live >> /opt/ghl/logs/client_readiness.log 2>&1 # is every client wired up per client_config_manifest.json? ⛔ subaccount_parity CANNOT answer this — its baseline is consensus, so something missing from EVERY client looks like agreement, and it only sees GHL assets, not sheet cells, tabs, formats or Stripe. 08:22 sits just before parity at 08:17+ so both land in one morning read. log /opt/ghl/logs/ · status /opt/ghl/status/clientready.last | daily at 8am:22 | 7h ago | Sun 08:22 | FAILED |
Keep ClickUp's Launch Date honest by taking it from the client's own… launchsync · launch_sync.py Kirk 2026-08-30: *"the launch date for each client will be in their appointment tracking sheet"*, and *"ensure the data like the launch date for another client does not get copied onto another"*. What it does, in fullKeep ClickUp's Launch Date honest by taking it from the client's own tracker. Kirk 2026-08-30: *"the launch date for each client will be in their appointment tracking sheet"*, and *"ensure the data like the launch date for another client does not get copied onto another"*. The launch date is the `Cycle 1 starts` cell on the tracker's Cycle Tracking tab — the day that client's ads actually began. This copies it onto the ClickUp client row so the board agrees with reality, and clears any Launch Date the tracker does not support. ⛔ WHY THIS EXISTS AT ALL. ClickUp's Launch Date field held **August 11, 2026 on ATLAS**. Atlas signed on 23 August — twelve days AFTER that supposed launch — and their tracker has no cycle start because they have not launched. The date is Inspect-A-Deck's, sitting on the wrong client's row, looking every bit as authoritative as a true one. One client's date had already been copied onto another before anybody wrote a line of this. ⛔ SO NOTHING IS WRITTEN UNLESS TWO INDEPENDENT IDENTIFIERS AGREE. 1. the tracker names its own client: the `GHL Sub-Account ID` cell, which maps to exactly one active client, and 2. the ClickUp row names the same one: its task name must match that client's name. A ClickUp row linking somebody else's tracker fails check 2, and a tracker claiming a sub-account we do not serve fails check 1. Either way the run REFUSES and says so, out loud, instead of carrying a date across from one client to another. Agreement of two independent identifiers is the only thing that makes a cross-client copy impossible; one identifier is just a guess with a source. python3 launch_sync.py # dry: say what it would change python3 launch_sync.py --live # change it cron 53 7 * * * /usr/bin/flock -n /tmp/launch_sync.lock /opt/ghl/run.sh launchsync launch_sync.py --live >> /opt/ghl/logs/launch_sync.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/launchsync.last | daily at 7am:53 | 8h ago | Sun 07:53 | 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 | 3h ago | Sun 12:30 | OK |
keep every internal tab hidden on every client tracker tabshidden · tabs_hidden.py Kirk 2026-09-29, after finding Inspect-A-Deck's `RidgeRev (internal)` tab — the one headed *"the client never sees this tab"* — sitting VISIBLE in his tab bar on a tracker shared to anyone with the link: *"To hide every hidden tab, every fi What it does, in fulltabs_hidden.py — keep every internal tab hidden on every client tracker. Every five minutes. Kirk 2026-09-29, after finding Inspect-A-Deck's `RidgeRev (internal)` tab — the one headed *"the client never sees this tab"* — sitting VISIBLE in his tab bar on a tracker shared to anyone with the link: *"To hide every hidden tab, every five minutes if I forget."* ⛔ SEVEN SCRIPTS SET `hidden` AND NOTHING EVER CHECKED IT STAYED SET. `client_roi_sheet`, `cycle_tracker`, `sheet_guide`, `to_send`, `submissions_log`, `billable` and `agent_knowledge` all hide a tab on creation. None re-asserts it. A tab only has to be un-hidden once, by anyone, and it stays un-hidden for ever with nothing to say so. ⛔ THIS REPAIRS, WHICH IS A DEPARTURE. `client_readiness` reports and never repairs, on the principle that a watch which fixes things hides how often it has to. That principle is right, so this job answers it directly: **every repair is written to the log with the date, the client and the tab**, so the frequency is visible rather than absorbed. If a tab keeps coming back, the log says so and something is un-hiding it that we have not found yet. ⛔ HIDDEN IS NOT PRIVATE, AND THIS MUST NOT BE READ AS MAKING IT SO. A viewer with the link can File > Download the whole spreadsheet, or copy it, and get every hidden tab regardless. This closes the accident that actually happened — an internal tab in plain sight on a tab bar the client opens — and it does not make the contents secret. Genuinely private means a separate file. ⛔ IT PAGES AT MOST ONCE PER TAB PER DAY. At twelve runs an hour, paging on every repair would be the notification flood Kirk has already objected to; and silence when there is nothing to do is why his alerts get read at all. python3 tabs_hidden.py # DRY — say what it would hide python3 tabs_hidden.py --live # hide them python3 tabs_hidden.py --selftest cron 29 * * * * /usr/bin/flock -n /tmp/tabs_hidden.lock /opt/ghl/run.sh tabshidden tabs_hidden.py --live >> /opt/ghl/logs/tabs_hidden.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/tabshidden.last | 24x a day (12am-11pm) | 53 min ago | Sat 16:29 | OK |
keep what a roofer said last time, when he says something new submissions · submissions_log.py Kirk 2026-09-27 is adding a "what can be improved" field to the disposition form and asked what happens when a roofer fills it in twice: *"it's gonna overwrite what was written first."* What it does, in fullsubmissions_log.py — keep what a roofer said last time, when he says something new. Kirk 2026-09-27 is adding a "what can be improved" field to the disposition form and asked what happens when a roofer fills it in twice: *"it's gonna overwrite what was written first."* ⛔ IT IS NOT JUST FEEDBACK. Every field on that form overwrites — revenue, notes, the disposition itself. A roofer who dispositions an appointment and then, weeks later, marks the job Won has already erased what he said the first time, and nobody has ever had the earlier version. ⛔ THE OVERWRITE HAPPENS INSIDE GHL AND CANNOT BE INTERCEPTED. The published workflow "Disposition Form -> Google Sheet Update" (40820e0e-8090-424c-a1c0-9bcbb7798ade) finds the homeowner's Campaign 1 row and replaces columns I-Q. Workflows have NO API — nothing can read or change a step. So history is captured by POLLING the contact's custom fields and appending when a value has changed, never by catching the write. ⛔ THE HONEST LIMIT: TWO SUBMISSIONS BETWEEN TWO POLLS LOOK LIKE ONE. Only the later survives. That cannot be engineered away without a webhook Kirk would have to add by hand inside the workflow. Running at dispo_pipeline's ten-minute cadence shrinks the blind spot from a day to ten minutes, and this is said out loud rather than implied — a log known to be coarse is worth more than one that silently drops a submission. [[no-silent-failures]] ⛔ A SEPARATE TAB, NOT ROWS ON CAMPAIGN 1. The KPI tab counts ROWS, not unique homeowners, for inspections, estimates, wins and needs-update — so a second row per homeowner would double-count every one of them. The tab is hidden, because it is a working record and not something a roofer reads. ⛔ AND THE FEEDBACK ITSELF NEVER GOES ON THE ROOFER'S OWN SHEET. Campaign 1 is what he opens to check his appointments; an accumulating column of everything he has said RidgeRev did badly is a complaints log on a trust instrument. Its value is across clients anyway — three roofers independently saying handoffs are slow is a finding no per-client column can show. python3 submissions_log.py # show what changed, write nothing python3 submissions_log.py --live python3 submissions_log.py --selftest cron 4,14,24,34,44,54 * * * * /usr/bin/flock -n /tmp/submissions_log.lock /opt/ghl/run.sh submissions submissions_log.py --live >> /opt/ghl/logs/submissions_log.log 2>&1 # keep what a roofer said LAST time when he says something new (Kirk 2026-09-27). The GHL workflow that overwrites Campaign 1 I-Q has no API and cannot be intercepted, so this polls and appends on change. 10-minute cadence keeps the blind spot at 10 min, offset 4 so it never collides with dispo_pipeline. log /opt/ghl/logs/ · status /opt/ghl/status/submissions.last | every 10 min, all day | 7 min ago | Sat 16:24 | OK |
keep what a roofer said last time, when he says something new feedbackview · submissions_log.py Kirk 2026-09-27 is adding a "what can be improved" field to the disposition form and asked what happens when a roofer fills it in twice: *"it's gonna overwrite what was written first."* What it does, in fullsubmissions_log.py — keep what a roofer said last time, when he says something new. Kirk 2026-09-27 is adding a "what can be improved" field to the disposition form and asked what happens when a roofer fills it in twice: *"it's gonna overwrite what was written first."* ⛔ IT IS NOT JUST FEEDBACK. Every field on that form overwrites — revenue, notes, the disposition itself. A roofer who dispositions an appointment and then, weeks later, marks the job Won has already erased what he said the first time, and nobody has ever had the earlier version. ⛔ THE OVERWRITE HAPPENS INSIDE GHL AND CANNOT BE INTERCEPTED. The published workflow "Disposition Form -> Google Sheet Update" (40820e0e-8090-424c-a1c0-9bcbb7798ade) finds the homeowner's Campaign 1 row and replaces columns I-Q. Workflows have NO API — nothing can read or change a step. So history is captured by POLLING the contact's custom fields and appending when a value has changed, never by catching the write. ⛔ THE HONEST LIMIT: TWO SUBMISSIONS BETWEEN TWO POLLS LOOK LIKE ONE. Only the later survives. That cannot be engineered away without a webhook Kirk would have to add by hand inside the workflow. Running at dispo_pipeline's ten-minute cadence shrinks the blind spot from a day to ten minutes, and this is said out loud rather than implied — a log known to be coarse is worth more than one that silently drops a submission. [[no-silent-failures]] ⛔ A SEPARATE TAB, NOT ROWS ON CAMPAIGN 1. The KPI tab counts ROWS, not unique homeowners, for inspections, estimates, wins and needs-update — so a second row per homeowner would double-count every one of them. The tab is hidden, because it is a working record and not something a roofer reads. ⛔ AND THE FEEDBACK ITSELF NEVER GOES ON THE ROOFER'S OWN SHEET. Campaign 1 is what he opens to check his appointments; an accumulating column of everything he has said RidgeRev did badly is a complaints log on a trust instrument. Its value is across clients anyway — three roofers independently saying handoffs are slow is a finding no per-client column can show. python3 submissions_log.py # show what changed, write nothing python3 submissions_log.py --live python3 submissions_log.py --selftest cron 56 8,20 * * * /usr/bin/flock -n /tmp/feedback_view.lock /opt/ghl/run.sh feedbackview submissions_log.py --feedback --live >> /opt/ghl/logs/feedback_view.log 2>&1 # the ONE cross-client feedback sheet (Kirk 2026-09-27: "where the feedback section sheet at?"). submissions_log has been collecting into a hidden per-client tab every 10 min with nowhere to read it, and the value of feedback is ACROSS clients — three roofers independently saying handoffs are slow is a finding no per-client column can show. Rebuilt whole from those tabs, newest first; a client whose tab cannot be read is NAMED, never silently absent. Twice a day is plenty for spotting a pattern. 08:56 sits clear of brevo 08:50, billingpage 08:52 and clientcall 08:59. log /opt/ghl/logs/ · status /opt/ghl/status/feedbackview.last | 2x a day (8am-8pm) | 7h ago | Sat 20:56 | 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 | 8 min ago | Sat 16:24 | 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) | 45 min ago | Sat 16:37 | OK |
Kirk hears about a roofer's payment BEFORE it happens rooferpay · roofer_payment_watch.py Kirk 2026-08-29: *"add a text to me that pages me when a payment for a roofer is 3 days away in stripe."* What it does, in fullroofer_payment_watch.py — Kirk hears about a roofer's payment BEFORE it happens. Kirk 2026-08-29: *"add a text to me that pages me when a payment for a roofer is 3 days away in stripe."* Three days is enough notice to do something about it: chase a card that is about to fail, hold a renewal for somebody mid-conversation about cancelling, or simply not be surprised. After the charge there is nothing to decide. ⛔ WHICH SUBSCRIPTIONS ARE ROOFERS IS DERIVED, NEVER LISTED. The Stripe account also bills B2B clients — on 2026-08-29 it carried Nicholas R Green at $997/mo and Jeffery Falamak at $1997/mo alongside the three roofers at $100/wk. A hardcoded list of customer ids would go stale the first time Kirk signs somebody, and would silently stop paging for whoever was added. So a subscription counts as a roofer's when its Stripe customer EMAIL matches the email GHL holds for one of the active client sub-accounts, via active_clients.client_identity. Verified live: Warren Tidwell → saberroofingok@gmail.com → Saber, ATLAS ROOFING MGMT → jmlayman3@hotmail.com → Atlas. [[derive-dont-hardcode]] ⛔ AND A PAST-DUE ROOFER IS SAID OUT LOUD EVERY DAY. A renewal three days out is a heads-up; a payment that has already failed is money we are owed and nobody is looking at Stripe daily. The one bill in that state today belongs to a former client, which is exactly how it goes unnoticed. ⛔ ONCE PER PERIOD, NOT ONCE PER RUN. The state is keyed on subscription id AND the period end it was warned about, so a renewal is announced once and a NEW period announces again. python3 roofer_payment_watch.py # print, page nobody python3 roofer_payment_watch.py --live # print and text Kirk cron 26 7 * * * /usr/bin/flock -n /tmp/roofer_payment_watch.lock /opt/ghl/run.sh rooferpay roofer_payment_watch.py --live >> /opt/ghl/logs/roofer_payment_watch.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/rooferpay.last | daily at 7am:26 | 8h ago | Sun 07:26 | OK |
Kirk hears about EVERY cancelled or moved sales call, from GHL itself salescalalerts · sales_calendar_alerts.py Kirk 2026-10-20: "had a call today at 6pm with scott weaver, who cancelled? i didn't.. so how i didn't get this in gmail or anywhere????!" Scott cancelled from the link in his confirmation email (audit log: subSource booking_widget) and GHL What it does, in fullsales_calendar_alerts.py — Kirk hears about EVERY cancelled or moved sales call, from GHL itself. Kirk 2026-10-20: "had a call today at 6pm with scott weaver, who cancelled? i didn't.. so how i didn't get this in gmail or anywhere????!" Scott cancelled from the link in his confirmation email (audit log: subSource booking_widget) and GHL told nobody, because on every active sales calendar the alerts TO KIRK for a cancellation or a reschedule were deleted or had never been created. Only "booked" was left on. This puts back, on every ACTIVE sales calendar (show_tracker.GROWTH_CALENDARS — the list the rest of the sales tracking already uses, so a new one is covered when it is added there): assigned user (Kirk) × email + in-app × cancellation + reschedule ⛔ ONLY the alerts that go to KIRK. Contact / guest notifications are left exactly as they are — turning one of those on would start emailing prospects, which nobody asked for. A deleted alert is undeleted (PUT); a missing one is created (POST) using GHL's own template copied from a calendar that has it — no invented wording. Every change is read back; unverified counts as FAILED. python3 sales_calendar_alerts.py # report only python3 sales_calendar_alerts.py --apply # fix + text Kirk what was fixed (daily cron) cron 13 8 * * * /opt/ghl/run.sh salescalalerts sales_calendar_alerts.py --apply >> /opt/ghl/logs/sales_calendar_alerts.log 2>&1 # keep GHL cancel+reschedule alerts TO KIRK (email + in-app) ON for every active sales calendar; turns back on and texts him if anyone switched one off. Kirk 2026-10-20: all 9 had them off. log /opt/ghl/logs/ · status /opt/ghl/status/salescalalerts.last | daily at 8am:13 | 8h ago | Sun 08:13 | OK |
Kirk's homeowner booking calls, one tracking sheet PER CLIENT b2c-appt-calls · b2c_call_tracker.py Kirk 2026-09-20: *"I do B2C calls using Wavv as well and some of those calls are mixed in with the B2B appointment-setting tracking. Create one tracking sheet PER CLIENT, exact same structure as the B2B sales performance tracker, put all of What it does, in fullb2c_call_tracker.py — Kirk's homeowner booking calls, one tracking sheet PER CLIENT. Kirk 2026-09-20: *"I do B2C calls using Wavv as well and some of those calls are mixed in with the B2B appointment-setting tracking. Create one tracking sheet PER CLIENT, exact same structure as the B2B sales performance tracker, put all of that client's homeowner calls in there with the recordings (Dropbox) and the summaries, run it on cron just like the regular appointment tracking. Follow the exact everything you do for B2B, just per client."* So this IS the B2B engine (call_tracker.py), pointed at a different sheet: • same Wavv export (/tmp/wavv_latest.csv), same qualifying rule (>=45s, or >=15s with a human disposition), same one-call-per-contact-per-day (longest wins); • same 12 columns B..M, header row 9, data from row 10, same colours (sheet_beautify.month_tab) plus the B2C outcome words coloured the same way: booked = green (B2B's WON), not interested = red (B2B's lost), reschedule = orange, follow-up = yellow; • same Claude analysis — the prompt is the B2B prompt with ONLY the business line and the outcome words changed (FRAMING / OUTCOMES below); Kirk's Wavv note on the call still beats Claude's read, but his SMS self-notes are NOT read (they are about his B2B calls — see enrich); • same "once BOOKED the row is final" rule as the B2B sheet's WON (STICKY_OUTCOMES); • same Dropbox mechanics, filed under /B2C Sales Calls/<client>/Appointment Booking/ — but NO shared link is made: the Recording cell is a Dropbox link that opens only for Kirk's signed-in Dropbox (call_tracker.dropbox_home_url). Kirk's Dropbox is a Pro plan with no team, so a team-only link cannot exist, and a homeowner's recording is never anyone-with-link (DBX_LINK_VISIBILITY); • same registry rule: a call is analysed once, ever, and only a REAL write earns the entry. WHICH CLIENT A CALL BELONGS TO is never typed anywhere. The number is looked up in every active client's sub-account (active_clients.ACTIVE + its GHL_CLIENT_<loc> token) with the exact-phone duplicate search, and the contact found is then put through active_clients.is_homeowner: the roofer himself, his staff, the agency, Kirk and anything tagged roofer/internal/test are NOT homeowners even though they are contacts in the sub-account. Only a 'yes' files the call here. The answer is cached per phone AND per client for TTL_DAYS as {contact id} only — never a name, email or phone — and the contact is read live from the client's GHL the moment a row is written. A number in NO client is a roofer — call_tracker's business. [[derive-dont-hardcode]] ⛔ The sheets are created in KIRK'S Drive through his own OAuth token and are never link-shared: they hold homeowner names, numbers and recordings. No permissions.create, ever. ⛔ A DRY RUN LEAVES NO MARK AND SPENDS NOTHING: no upload, no sheet write, no registry entry, no cache file, no ClickUp write, no text to Kirk, and no Claude call unless --analyze is given. (call_tracker learned the first half of this the hard way on 2026-09-19.) ⛔ STATE NEVER LANDS IN THE GIT TREE. On the server it is /opt/ghl/state; anywhere else it is 3 - Operations/scripts/state/b2c_calls/, which .gitignore excludes (as it does the three file names themselves, wherever they appear). Usage: b2c_call_tracker.py # dry run of /tmp/wavv_latest.csv, last 30 days b2c_call_tracker.py --live # write b2c_call_tracker.py --live --days 40 # first run / backfill b2c_call_tracker.py --client <loc> # one client only (location id, or part of its name) b2c_call_tracker.py --analyze # dry run that also runs the Claude summary (costs) b2c_call_tracker.py --selftest # fakes only, no network cron 45 5 * * * /usr/bin/flock -n /tmp/b2c_calls.lock /opt/ghl/run.sh b2c-appt-calls b2c_call_tracker.py --live --csv /tmp/wavv_latest.csv >> /opt/ghl/logs/b2c_calls.log 2>&1 # Kirk's homeowner booking calls, one private sheet per client (Kirk 2026-09-20, "follow the exact everything you do for B2B, just per client"). Reads the SAME Wavv export the 05:20 B2B run reads (pulled 03:40 by callhist-night); a number that is a homeowner in a client sub-account is filed here and skipped there. 05:45 is after the B2B run and clear of every explicit series at hour 5 (health :00 Sun, docs-drive :00, appt-calls :20); flock so a slow backfill cannot overlap the next night. Needs b2c_call_tracker.py + active_clients.py deployed flat in /opt/ghl/scripts - the 05:20 run imports the first and says COULD NOT CHECK (keeps filing homeowners under B2B) until it is there. log /opt/ghl/logs/ · status /opt/ghl/status/b2c-appt-calls.last | daily at 5am:45 | 10h ago | Sun 05:45 | 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 | 5d 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 | 5d 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 * * * /usr/bin/flock -n /tmp/planner_watch.lock /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 | 2 min ago | Sat 16:24 | OK |
Make next year's Billing & Churn sheet before the year turns, and make it… billroll · billing_rollover.py Kirk 2026-08-30: *"cron the creation of relevant new tabs for 2027 and beyond when that 2026 is getting old."* What it does, in fullMake next year's Billing & Churn sheet before the year turns, and make it EMPTY. Kirk 2026-08-30: *"cron the creation of relevant new tabs for 2027 and beyond when that 2026 is getting old."* ⛔ THE COPY IS THE EASY HALF. THE CLEARING IS THE POINT. The 2026 sheet was a copy of 2025 that nobody emptied, and it carried the whole of last year forward wearing this year's name: three 2024- 25 clients still listed, two of them marked Active when they had stopped paying, January and February holding 2025's numbers, and a `Retention in Months` of 3 that dragged the YTD average to 3.00 for eight months. Every one of those looked like a fact about 2026. A rollover that copies and does not clear does not save the work, it just moves the mistake a year earlier and makes it look automatic. So a fresh year starts with: the client rows gone, the KPI months gone, and every FORMULA kept — because the formulas are the sheet, and they are what recompute once real rows land. ⛔ IT TAKES TWO CREDENTIALS, AND NEITHER CAN DO IT ALONE. The obvious way — the service account copies the file — fails with "The user's Drive storage quota has been exceeded": a service account has NO Drive storage and cannot own a file at all. `canCopy: True` on the source means the file may be copied, not that this account can keep a copy. Kirk's own OAuth has the storage (200GB) but is `drive.file` scoped, so it 404s on a sheet it did not create — which is every sheet he made by hand. So: KIRK'S OAUTH CREATES the new spreadsheet, which puts it in HIS Drive under HIS ownership, shares it with the service account, and the SERVICE ACCOUNT copies the tabs across with `sheets.copyTo`, which carries formulas and formatting intact. Proven end to end before this was written, because November is a bad time to find out. ⛔ THE PAYMENT METHODS TAB IS DELIBERATELY NOT CARRIED FORWARD. Only Dashboard and KPIs are copied. A new year is the natural moment for card details to stop being duplicated into another file. ⛔ AND IT NEVER TOUCHES THE YEAR IN PROGRESS. It only ever writes into the file it has just created, identified by the id the copy returned. There is no path here that can clear a live sheet. Runs daily and does nothing at all for ten months of the year. python3 billing_rollover.py # dry: say what it would do python3 billing_rollover.py --live # do it python3 billing_rollover.py --live --force # build next year's now, whatever the date cron 35 7 * * * /usr/bin/flock -n /tmp/billing_rollover.lock /opt/ghl/run.sh billroll billing_rollover.py --live >> /opt/ghl/logs/billing_rollover.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/billroll.last | daily at 7am:35 | 8h ago | Sun 07:35 | 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 | 11h ago | Sun 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 | 9d ago | Sun 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 | 29d ago | Sun 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, state} (live data + Claude memory + skills + the state files automations keep between runs) and uploads to Dropbox /RidgeRev-Backups/. Scripts themselves are in git (recoverable) so they're skipped; t 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, state} (live data + Claude memory + skills + the state files automations keep between runs) 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). ⛔ `state` WAS MISSING UNTIL 2026-09-03, and it is the one directory here that cannot be rebuilt. Everything in outputs/ can be regenerated from a re-run; /opt/ghl/state holds the append-only ledgers that ARE the source of truth — photo numbers behind every ad name, dedup keys that stop a homeowner being texted twice, alert cooldowns. Losing it does not lose a report, it permanently detaches names from the things they name. Caught while planning the ad-creative system, whose photo registry would have been the next irreplaceable file living outside the backup. ⛔ THE ARCHIVE IS BUILT IN RAM (io.BytesIO) before it is uploaded, so the whole backup has to fit in memory alongside everything else on the box. Nothing large-and-regenerable — rendered images especially — may be written under these directories. 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 | 10h ago | Sun 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 | 6d 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 | 9 min ago | Sat 16:43 | OK |
one evening text telling each roofer what tomorrow looks like tomorrowdigest · tomorrow_digest.py Kirk 2026-08-26: *"please add this ---> tomorrow's inspections digest"*. What it does, in fulltomorrow_digest.py — one evening text telling each roofer what tomorrow looks like. Kirk 2026-08-26: *"please add this ---> tomorrow's inspections digest"*. Nothing told a contractor what was on his calendar tomorrow, or which of those people had actually confirmed. He had two ways to find out: open GHL, or drive out and see. Everything else we built today is a message that fires when something GOES WRONG, and a system that only ever speaks up with bad news is one a client learns to dread. This is the one that says the day is fine — and, once a day, gives him a chance to spot a job he was not expecting. ⛔ THE ROOFER'S MESSAGE SAYS CONFIRMED, BECAUSE THEY ARE. Kirk 2026-08-28: *"we spoke to them and they agreed on a time, so as far as I'm concerned all the appointments are confirmed. If we tell them unconfirmed, that's problematic."* Right — "unconfirmed" in our pipeline means "has not RE-confirmed since booking", an internal chase state, and printing it on a client's phone made his own calendar look unreliable. It goes out with his own disclaimer instead: give them a quick call before you head out. If that call turns something up, he rebooks it on the spot; if he cannot reach them, he marks it on the form and we take over. ⛔ THE CHASE STATE IS STILL READ, AND STILL MATTERS — it just goes to KIRK. Anybody with an inspection tomorrow who has never once answered a call is a real risk of a wasted drive, and acting on that is our job, not the roofer's. `appointmentStatus` cannot answer it (it reads "confirmed" on essentially everything, GHL's own default); the OPPORTUNITY's pipeline stage can, and is the only thing appt_confirm_watch ever writes. Matched by NAME, since ids differ per sub-account. ⛔ INFORMATIONAL ONLY. It never asks him to chase anybody or ring anybody. Kirk, standing rule: *"we do all that for them... all the calling and followups etc before the inspection itself."* It sends only when there IS something tomorrow. A nightly "nothing booked" text is noise, and noise is how a message stops being read — so silence means an empty day. python3 tomorrow_digest.py # show what would go out, send nothing python3 tomorrow_digest.py --live python3 tomorrow_digest.py --live --day 2026-08-31 # a specific day, for testing See skills: no-silent-failures, cron-timing-collisions, client-tracker-sheets. cron 10 18 * * * /opt/ghl/run.sh tomorrowdigest tomorrow_digest.py --live >> /opt/ghl/logs/tomorrow_digest.log 2>&1 # one evening text per roofer listing tomorrow-s inspections and which are still unconfirmed (Kirk 2026-08-26). Informational only. Silent when he has an empty day. 18:10 CT dodges 18:30/18:45/19:02/20:00/20:30. log /opt/ghl/logs/ · status /opt/ghl/status/tomorrowdigest.last | daily at 6pm:10 | 22h ago | Sat 18:10 | OK |
one hidden tab per client: what the machine has decided, and what is… whatsnext · whats_next.py Kirk 2026-09-29: *"i need a sheet that has all the messages scheduled to go out to these guys... before they are sent so i can see all at a glance"* and then *"add it to the tracking sheet... any way to put that decision making layer in the 4 client(s) · 1 problem(s) What it does, in fullwhats_next.py — one hidden tab per client: what the machine has decided, and what is queued. Kirk 2026-09-29: *"i need a sheet that has all the messages scheduled to go out to these guys... before they are sent so i can see all at a glance"* and then *"add it to the tracking sheet... any way to put that decision making layer in the tracking sheet too and have it constantly updated so it can be visible?"* So it goes on each client's own tracker, as `What happens next` — hidden, like `To send`, which is where the last "put them in the tracking sheet" landed. Rebuilt every 20 minutes. ⛔ READ-ONLY. Nothing here sends, approves, extends or bills. It is the window, not the lever — you still type SEND and OK exactly where you always did. A second place to approve something is a second thing that can disagree about whether it was approved. ⛔ IT ASKS THE SENDERS, IT DOES NOT RE-DECIDE. Every line comes from calling the code that actually owns the decision — `cycle_notice.state_of`, `cycle_state.verdict`, `cycle_notice.due`, the `cycle_messages` composers, `terms_reminder.due`/`draft` — or from reading what a sender has already written down (`to_send`'s tab). ⛔ Two things computing one verdict is two things that can disagree about one client in one week, which this codebase has already had once between `cycle_alert.main()` and `client_states()`. The only thing this file decides is which function to ask. ⛔ "HELD" IS A DECISION, NOT AN ABSENCE. `state_of` returns nothing for a client still on the old promise, and a tab that rendered that as blank would read as "nothing happening" when the truth is "everything for this client is waiting on you typing GO". The reason is printed. ⛔ AND WHAT IS NOT AUTOMATIC IS SAID PLAINLY ON THE TAB. The machine decides to extend but cannot write the date; it proposes a Stripe move but cannot apply it without a typed OK; and nothing freezes delivery on a failed card. A panel that shows a decision without saying who has to act on it reads as though it already acted. python3 whats_next.py # DRY python3 whats_next.py --live python3 whats_next.py --selftest cron 19-59/20 * * * * /usr/bin/flock -n /tmp/whats_next.lock /opt/ghl/run.sh whatsnext whats_next.py --live >> /opt/ghl/logs/whats_next.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/whatsnext.last | every 20 min, all day | 3 min ago | Sat 16:39 | FAILED |
One honest answer per client: what a complete client is, what this one… reconcile · client_reconcile.py Kirk 2026-10-20: *"give me a ten out of ten one million dollar system… very seamless to onboard them across every single area."* He picked all three of one honest screen, fewer manual touches, and nothing failing silently. What it does, in fullOne honest answer per client: what a complete client is, what this one has, what is owed. Kirk 2026-10-20: *"give me a ten out of ten one million dollar system… very seamless to onboard them across every single area."* He picked all three of one honest screen, fewer manual touches, and nothing failing silently. ⛔⛔ THIS REPLACES A DONE-FLAG WITH A MEASUREMENT. `onboard_pipeline` marks a client `done` (:640) and then skips them for ever (:488), so a step added in October never reaches a client onboarded in August: Atlas and Saber each carry 8 steps against a pipeline that now defines 11, and `declared` had never run for anyone until today. Nothing re-checked a client once. Atlas's self-booked calendar sat with a blank meeting location for eight weeks. So: no client is ever finished. Every tick measures every client against the manifest and says what is owed. A client who drifts is caught on the next pass, with no backfill job to remember. ⛔ IT ASKS THE OWNING MODULE, IT NEVER FORMS A THIRD OPINION. custom values + fields + sheet tabs + Stripe -> client_readiness workflows · forms · calendars · tags · pipelines · trigger links -> subaccount_parity A2P -> create_subaccount.a2p_state (column F) assets present at all -> create_subaccount.asset_counts Two modules measuring the same thing is how they come to disagree, and the disagreement shows up as a number nobody can explain. [[derive-dont-hardcode]] ⛔ REPORT ONLY, DELIBERATELY. It fixes nothing yet. client_readiness has carried the same rule since it was written — *"AND IT REPORTS, IT NEVER REPAIRS… a watch that fixes things hides how often it has to."* Repair comes after this has been right for a run of days, and then only for the entries the manifest marks `satisfied_by: api`. [[skill-supervised-release]] cron 30 8-20 * * * /usr/bin/flock -n /tmp/client_reconcile.lock /opt/ghl/run.sh reconcile client_reconcile.py --live >> /opt/ghl/logs/client_reconcile.log 2>&1 # measures EVERY client against the manifest every hour and says what is owed and who owes it. Replaces the done-flag model: no client is ever finished, so a step added in December reaches a client onboarded in August with no backfill. REPORT ONLY - it repairs nothing yet (supervised-release). :30 is chain-free (cycle_chain is 6-56/5) and 5 min before the board at :35 so the board reads a fresh snapshot. log /opt/ghl/logs/ · status /opt/ghl/status/reconcile.last | 13x a day (8am-8pm) | 52 min ago | Sat 16:30 | OK |
one page that answers "where does every client stand", on ads.ridgerev.com billingpage · billing_page.py Kirk 2026-09-27: *"I want a ten million dollar system that automates itself fully… we can just focus on booking the appointments and know everything else is coordinated properly."* What it does, in fullbilling_page.py — one page that answers "where does every client stand", on ads.ridgerev.com. Kirk 2026-09-27: *"I want a ten million dollar system that automates itself fully… we can just focus on booking the appointments and know everything else is coordinated properly."* Knowing everything else is coordinated needs somewhere to look. Today that answer is spread across a cycle tab, a Stripe dashboard, a hidden approval column and four cron logs, so the only way to know where a client stands is to go and assemble it — which is the same reason the dispute pack exists. This assembles it once a day and puts it behind the password that is already there. ⛔ IT DECIDES NOTHING AND WRITES NOTHING TO STRIPE OR A SHEET. `cycle_notice` and `billing_sync` own every decision and every write. This reads what they decided. A second thing that computes a billing verdict is a second thing that can disagree about one client in one week, which is the exact fault this codebase already carries between `cycle_alert.main()` and `client_states()`. ⛔ AND IT NEVER RENDERS A CLIENT AS FINE BECAUSE IT COULD NOT READ HIM. A dashboard's failure mode is a green row, and a green row is how a paused subscription goes six weeks without being noticed. Anything unreadable is a red row that names what could not be read. [[no-silent-failures]] python3 billing_page.py # what it would render python3 billing_page.py --out /tmp/b.html python3 billing_page.py --deploy # -> /var/www/ads/billing.html, on the server python3 billing_page.py --selftest cron 52 8 * * * /usr/bin/flock -n /tmp/billing_page.lock /opt/ghl/run.sh billingpage billing_page.py --deploy >> /opt/ghl/logs/billing_page.log 2>&1 # where every client stands, on ads.ridgerev.com/billing.html (Kirk 2026-09-27: "we can just focus on booking the appointments and know everything else is coordinated properly"). ⛔ READS ONLY — cycle_notice and billing_sync own every decision and every write; a second thing computing a billing verdict is a second thing that can disagree. 08:52 sits after cyclewatch 08:33, cyclenotice 08:41 and billingsync 08:46, so the page shows what this morning actually decided rather than yesterday. log /opt/ghl/logs/ · status /opt/ghl/status/billingpage.last | daily at 8am:52 | 7h ago | Sun 08:52 | 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 | Sat 16:37 | OK |
one page that says what to fix, and nothing that cannot be acted on b2cboard · b2c_board.py Kirk 2026-08-28: *"your data tracking system/structure sucks... hard to interpret and hard to feel good looking at... you need properly tracked shit with a proper system, not just throwing numbers on a sheet... so it can be leveraged into d What it does, in fullb2c_board.py — one page that says what to fix, and nothing that cannot be acted on. Kirk 2026-08-28: *"your data tracking system/structure sucks... hard to interpret and hard to feel good looking at... you need properly tracked shit with a proper system, not just throwing numbers on a sheet... so it can be leveraged into decision later."* He was right, and the old sheet proved it on its own: nine tabs with no answer to which one you open first, four of them computing answer rate four different ways, Leads and Speed describing the same people, a sample-size disclaimer stamped on every cell so the whole thing read as untrustworthy, no trend anywhere — and a live "-100%" from a numerator that could exceed its own denominator. ⛔ COUNTS BEFORE RATES, ALWAYS. At forty calls a day a count is a fact and a rate is an opinion. Everything on this board is a count of named people, so it is true at n=1 and useful the day it ships. Rates come later, on their own tab, once each step has met its own minimum. ⛔ AND A STAGE BEFORE A NUMBER. Saber and Atlas show zero leads, zero calls, zero everything — and they are in ONBOARDING with ads not yet live. The research read that as two dead paying clients, and the old sheet showed it as blank space. Identical numbers, opposite meanings: a client whose ads never launched and a client whose ads broke must never look the same. So the stage is the first thing on the row and it decides whether the zeroes are calm or on fire. python3 b2c_board.py # print it, write nothing python3 b2c_board.py --live # write the sheet cron 9 7,15 * * * /usr/bin/flock -n /tmp/b2c_board.lock /opt/ghl/run.sh b2cboard b2c_board.py --live >> /opt/ghl/logs/b2c_board.log 2>&1 # the one page that says what to fix (Kirk 2026-08-28, the old nine-tab sheet was unreadable). Counts only, no rates: a stage before every number so an onboarding client reads calm and a broken one reads red, named people in every fire, and COULD NOT CHECK wherever a read failed. Minute 9 is odd and not divisible by 3, dodging */2 planner-watch and /3 zoomwatch, unused by every explicit series. 7am before he starts, 3pm mid-afternoon. log /opt/ghl/logs/ · status /opt/ghl/status/b2cboard.last | 2x a day (7am-3pm) | 73 min ago | Sun 07:09 | OK |
one row per client, per DAY, per grouping cortanadaily · cortana_daily.py Kirk 2026-09-25: *"I want it to be like I'm using another cortana essentially.... like exactly everything.. But it doesn't lose data..."* What it does, in fullcortana_daily.py — one row per client, per DAY, per grouping. The store a replacement needs. Kirk 2026-09-25: *"I want it to be like I'm using another cortana essentially.... like exactly everything.. But it doesn't lose data..."* Cortana's attribution screen lets him pick any date range. To answer any range without asking Cortana — which is the whole point, since Cortana may not have it after a churn — the numbers have to be held **per day**, so every range he picks is a sum. ⛔ dailySummary IS A DECOY AND COST AN HOUR. Every attribution response carries a `dailySummary` key and it is ALWAYS an empty list. Probed 2026-09-25: `dateBucketMode=day` 400s, `groupBy=day` 400s, `include=dailySummary` 400s, and `granularity=day` / `interval=day` / `includeDailySummary=true` are all silently ignored and return it empty again. An independent audit reached the same conclusion from the archived files. **The only route to daily data is one call per day.** Do not go looking for the parameter again. ⛔ THE DONE-LOG IS WHAT MAKES THIS CORRECT, AND IT IS EASY TO LEAVE OUT. A day on which a client spent nothing writes NO rows. Without a separate record of which days were actually pulled, that day is indistinguishable from a day never fetched — so it gets re-pulled for ever, and worse, a day that genuinely FAILED looks exactly like a day that was quiet. So every successful pull is logged by `(client, day, groupBy)` whether or not it produced rows, and anything absent from that log is fetched. A failure simply never lands in the log and is retried tomorrow. [[no-silent-failures]] ⛔ TODAY IS WRITTEN, BUT ONLY BECAUSE IT IS RE-WRITTEN. A part-day would be wrong to freeze into a store that never revisits a day; this one rewrites the last SETTLE_DAYS every night, so today lands partial and is corrected tomorrow. Excluding it made the page disagree with Cortana, which shows today — and for a replacement, agreeing with the thing it replaces is the entire job. ⛔ SLIM ON PURPOSE. The archive next door keeps all 79 fields per row. This keeps the seven the table shows, because 79 fields x ~87 rows x ~87 days x 3 clients is megabytes a browser would have to parse to render a column nobody asked for. Full fidelity already exists; this is the index. ⛔ THE DAY BOUNDARY IS THE API'S, NOT OURS. Days are requested as `<day>T00:00:00Z` to `<day>T23:59:59Z`. Whether Cortana buckets on UTC or on the business timezone is not documented and cannot be read from the API. What proves the store correct is not a belief about the boundary but `--reconcile`: the sum of every day must equal a single lifetime pull over the same window, to the cent. If a boundary were wrong, spend would land in the wrong day and the total would still match — so `--reconcile` also spot-checks individual days against a live single-day pull. [[timezone-day-attribution]] python3 cortana_daily.py --backfill --live # once, watched — ~1,050 calls, ~7 minutes python3 cortana_daily.py --live # nightly: yesterday + the day before python3 cortana_daily.py --reconcile # prove the sum equals the lifetime figure python3 cortana_daily.py --selftest cron 35 2 * * * /usr/bin/flock -n /tmp/cortana_daily.lock /opt/ghl/run.sh cortanadaily cortana_daily.py --live >> /opt/ghl/logs/cortana_daily.log 2>&1 # one row per client per DAY per grouping, so any date range is a SUM and Cortana losing it costs nothing (Kirk 2026-09-25). Pulls yesterday + the day before only, because Meta figures settle late. 02:35 sits between cortana_archive (02:20) and ad_performance (02:45) — all three hit the SAME rate-limited API. log /opt/ghl/logs/ · status /opt/ghl/status/cortanadaily.last | daily at 2am:35 | 13h ago | Sun 02:35 | 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) | 54 min ago | Sat 16:28 | OK |
our own full copy of everything Cortana holds, before it can go away cortanaarchive · cortana_archive.py Kirk 2026-09-25: *"if someone churns and I delete their ad account data, does it go away in Cortana too? Or can I preserve them? Before?"* then *"fully preserve... detail like snapshot"*. What it does, in fullcortana_archive.py — our own full copy of everything Cortana holds, before it can go away. Kirk 2026-09-25: *"if someone churns and I delete their ad account data, does it go away in Cortana too? Or can I preserve them? Before?"* then *"fully preserve... detail like snapshot"*. ⛔ THE FIRST QUESTION CANNOT BE ANSWERED FROM HERE, AND THAT IS ITSELF THE FINDING. Probed live 2026-09-25: there is no /integrations, no /ad-accounts and no /connections endpoint — all 404. The API cannot even report WHICH ad account is connected, so it certainly cannot tell us what happens when one is removed. The only way to find out is to disconnect a live client's ad account and see what disappears, which is not a test anyone runs on a paying client. ⛔ COULD NOT CHECK, and it stays could-not-check. [[no-silent-failures]] ⛔ SUNSET IS NOT EVIDENCE. Sunset Siding is absent from /businesses, and an earlier read of mine treated that as a clue. It is not: they churned 2026-08-27 and Cortana was connected 2026-09-24, so they were never in it, and Kirk confirms he never deleted their data. That absence means nothing. WHAT THE STRUCTURE DOES SAY, and it is the useful half. One attribution row carries two kinds of number with two different owners: spent, impressions, clicks, cpm, reach, metaPlatformLeads <- read FROM the Meta ad account leads, appointment_booked, appointment_shown, purchase <- CORTANA's own pixel and GHL feed So the halves cannot be assumed to behave alike. The spend side is Meta's data seen through a connection; remove the connection and there is no reason to expect it to survive. The conversion side is Cortana's own record, keyed to the business rather than the ad account. ⛔ REASONING ABOUT WHERE DATA COMES FROM IS NOT THE SAME AS KNOWING WHAT THEIR SERVERS DELETE. It narrows the risk; it does not remove it, which is why this file exists rather than a reassurance. ⛔ LEAD FORMS ARE NOT IN CORTANA AT ALL. /leadforms, /lead-forms and /forms are all 404. A lead form is a META object, so deleting one is a Meta operation against Meta's retention, and nothing in Cortana's API sees it either way. ⛔ Deleting a form is also how the LEAD HISTORY behind it goes: Meta keeps downloaded-lead access tied to the form and the page, so the safe order is always export first, delete second. This archive covers the Cortana side only and does not pretend to cover Meta. SO: KEEP OUR OWN COPY OF EVERYTHING, EVERY NIGHT, AND THE QUESTION STOPS MATTERING. ⛔ FULL DETAIL, NOT A SUMMARY. Kirk asked for a snapshot, and a row of totals is not one. Every endpoint is dumped whole, every page followed, raw JSON exactly as returned. A summary throws away the fields nobody has needed yet, which are exactly the fields you wish you had after the data is gone. ⛔ ATTRIBUTION IS PULLED LIFETIME *AND* MONTH BY MONTH. A single lifetime pull collapses every month into one row and the shape of the campaign is lost for ever. The months cost four extra calls. ⛔ NOTHING IS EVER OVERWRITTEN. Each run writes its own dated folder. An archive that rewrites itself is not an archive, and the whole point is to survive a day when the live data is wrong or gone. ⛔ AND IT LEAVES THE BOX. A copy on the server dies with the server, so each run uploads a single zip to Drive, which Kirk owns and which outlives any machine here. python3 cortana_archive.py # show what it would pull python3 cortana_archive.py --live python3 cortana_archive.py --live --client "Inspect-A-Deck" python3 cortana_archive.py --selftest See skills: no-silent-failures, resilient-runs, derive-dont-hardcode, caching. cron 20 2 * * * /usr/bin/flock -n /tmp/cortana_archive.lock /opt/ghl/run.sh cortanaarchive cortana_archive.py --live >> /opt/ghl/logs/cortana_archive.log 2>&1 # our OWN full copy of everything Cortana holds, per client, before a churn or a disconnect can take it (Kirk 2026-09-25). Every endpoint, every page, raw, plus attribution lifetime AND month by month, zipped to Drive so it outlives this box. 02:20 keeps it clear of cortana_sheet at 23:10, which hits the SAME rate-limited API. log /opt/ghl/logs/ · status /opt/ghl/status/cortanaarchive.last | daily at 2am:20 | 13h ago | Sun 02:20 | 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 | Sat 16:23 | 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 | just now | Sat 16:27 | OK |
put the homeowner's own photos in front of the roofer, where they belong grouprelay · group_relay.py Kirk 2026-09-27, after re-sharing Clarence McBride's roof photos by hand: *"is it possible that for future reference we reshare any pics or important messages shared to us individually by the homeowner back into the group?"* What it does, in fullgroup_relay.py — put the homeowner's own photos in front of the roofer, where they belong. Kirk 2026-09-27, after re-sharing Clarence McBride's roof photos by hand: *"is it possible that for future reference we reshare any pics or important messages shared to us individually by the homeowner back into the group?"* A homeowner texts the client's number with five photos of their roof. Those photos are exactly what the roofer needs and he cannot see them — they landed on the 1:1 line. Kirk forwards them himself, when he notices. ⛔ POSTING INTO A GROUP IS THE THING THAT WENT WRONG IN SEPTEMBER. Three bot messages landed on top of a real conversation between Traci and Tom and Kirk had to apologise to a roofer he was already not on good terms with: *"the roofer is in the fucking group, he can see himself"*. The rule since is that bots stay out. [[feedback_group_thread_silence]] This is a narrow, deliberate exception, and Kirk drew the line himself on 2026-09-27: PHOTOS -> posted to the group on their own. A roof photo obviously belongs in front of the roofer and can barely be the wrong thing to send. ANYTHING -> ⛔ NEVER posted. Drafted to Kirk, who forwards it himself if it should go. TYPED "can I cancel?" and "is this guy any good?" arrive on the same line as the photos, and neither may ever reach the roofer by machine. ⛔ THE FIRST RUN MUST NOT FLOOD. Twenty-six threads already exist, each with a homeowner's whole history behind it — Clarence alone has five photos from before this script existed. A naive first pass would post all of it into every group at once. On first sight of a contact every existing message is recorded as SEEN and nothing is sent; only what arrives afterwards is ever relayed. python3 group_relay.py # what it would forward, sends nothing python3 group_relay.py --live python3 group_relay.py --selftest cron 8,38 * * * * /usr/bin/flock -n /tmp/group_relay.lock /opt/ghl/run.sh grouprelay group_relay.py --live >> /opt/ghl/logs/group_relay.log 2>&1 # a homeowner texts the 1:1 line with photos of their roof and the roofer cannot see them. Kirk 2026-09-27, after forwarding Clarence McBride's five by hand. PHOTOS post into the group on their own; anything TYPED is judged and, until TEXT_SENDS_ITSELF is flipped, drafted to Kirk with its reason rather than posted. FLOCK because a stacked run would post every photo TWICE and the later save would hide it. :08/:38 dodges group_intro at every-5 and group_followup at :13/:28/:43/:58 - both write to these same threads. First sight of a thread adopts its history and sends nothing; Clarence's 21 messages are already marked handled. log /opt/ghl/logs/ · status /opt/ghl/status/grouprelay.last | every 30 min, all day | 14 min ago | Sat 16:38 | OK |
put the real ad numbers on every client's KPI tab, from Cortana cortanasheet · cortana_sheet.py Kirk 2026-09-24: *"add the ad tracking to all the clients trackers… refine sheet tracking according to new plan and approach."* The funnel he asked for, as plain counts a roofer can check against his own rows: spend, leads, appointments boo What it does, in fullcortana_sheet.py — put the real ad numbers on every client's KPI tab, from Cortana. Kirk 2026-09-24: *"add the ad tracking to all the clients trackers… refine sheet tracking according to new plan and approach."* The funnel he asked for, as plain counts a roofer can check against his own rows: spend, leads, appointments booked, showed. ⛔ THE CELL THIS REPLACES HAS BEEN WRONG ON A LIVE CLIENT SHEET FOR WEEKS. `Total Ad Spend` (F1) is the one cell on the KPI tab a human types. On 2026-09-24 Inspect-A-Deck's said **1800**. Cortana said the real figure was **2716.10**. Every ROAS that roofer has ever seen was computed against a denominator a third too small. Meanwhile his OTHER hand-typed spend cell — Cycle Tracking B6, $60/day — implied ~$2,640, which was nearly right. **Two typed cells, different units, neither checked against the other, and the wrong one drives the client-facing number.** This ends that: one source, both cells derived from it. ⛔ AND THE ROAS GUARD ONLY EVER CAUGHT HALF THE FAILURE. The formula was written after a roofer was shown a confident `0` — "indistinguishable from 'we spent your budget and returned nothing'" — but it only tests for an EMPTY F1: =IF(N(F1)=0, "⚠ type Total Ad Spend into F1", IFERROR(F2/F1, …)) Tom's F1 is filled and his Revenue is $0 because every deal is quoted and none closed. **So he is being shown `0x return` today, which is the exact thing the guard exists to prevent, one step removed.** Fixed here in the same pass, because writing a correct spend into a formula that still lies would make the lie more convincing. WHAT IS WRITTEN, and nothing else: E/F block only. The disposition COUNTIFs in A/B are never touched. F1 Total Ad Spend <- Cortana `spent` (was typed) F4 Return on Ad Spend <- repaired formula E6:F9 the ad block <- Leads / Appointments booked / Showed, all from Cortana ⛔ ROWS ARE FOUND BY LABEL, NEVER BY NUMBER. [[client-tracker-sheets]] — a row index drifts the moment anyone inserts a line, and a tracker number that is wrong still looks like a number. ⛔ UNREADABLE IS NEVER ZERO. If Cortana cannot be read for a client, that client's cells are LEFT ALONE and the run says so. A zero on a roofer's sheet is a claim about his money. python3 cortana_sheet.py # show every cell it would write, change nothing python3 cortana_sheet.py --live python3 cortana_sheet.py --client "Saber" python3 cortana_sheet.py --selftest See skills: client-tracker-sheets, no-silent-failures, supervised-release, batched-sheets-writes. cron 10 23 * * * /usr/bin/flock -n /tmp/cortana_sheet.lock /opt/ghl/run.sh cortanasheet cortana_sheet.py --days 120 --live >> /opt/ghl/logs/cortana_sheet.log 2>&1 # real ad spend from Cortana onto every KPI tab (Kirk 2026-09-24). Replaces the ONE hand-typed cell, which was wrong on all three: Inspect-A-Deck read 1800 against 2716.10 real. Minute 10 is unused at hour 23, clear of lp-selfheal at :00 and cycletracker at :20. log /opt/ghl/logs/ · status /opt/ghl/status/cortanasheet.last | daily at 11pm:10 | 17h ago | Sat 23:10 | OK |
put the roofer and the homeowner in ONE SMS thread, from the client's own… groupintro · group_intro.py WHAT THIS REPLACES Today the handoff is a relay: roofer_comms tells the contractor, b2c_post_booking tells the homeowner, and the two never see each other. This opens a real group thread so they can settle access, gate codes, dogs and timin What it does, in fullgroup_intro.py — put the roofer and the homeowner in ONE SMS thread, from the client's own line. WHAT THIS REPLACES Today the handoff is a relay: roofer_comms tells the contractor, b2c_post_booking tells the homeowner, and the two never see each other. This opens a real group thread so they can settle access, gate codes, dogs and timing between themselves before anyone drives anywhere. The point is not the message — it is that the ROOFER becomes the human owner of the visit instead of us. THE ROUTE IS UNDOCUMENTED AND THAT IS THE WHOLE RISK POST /conversations/group and /conversations/group/messages are real, on the public host, and in no spec. No private integration token may call them (401, "not authorized for this scope"), and the scope does not exist in GHL's published catalogue to be granted. So this runs on a Firebase ID token minted by ghl_firebase_token.py. GHL can change or close that route without notice. ⛔ SO A FAILURE MUST COST THE UPGRADE, NEVER THE HANDOFF. Every failure path falls through to roofer_comms.send(), which is exactly what happens today. A broken group thread degrades to the current behaviour; it never drops the contractor's notice. That fallback is the only reason building on an undocumented endpoint is defensible at all. ⛔ THE STATE FILE IS THE ONLY RECORD THAT A THREAD EXISTS. /conversations/search NEVER returns group conversations — verified across five query shapes and 2,100 conversations. A group is reachable ONLY by id. Nothing can rediscover one, so losing STATE means opening a second group on the same homeowner forever. ⛔ EVERY PRECONDITION IS CHECKED BEFORE THE CALL, because the error afterwards says nothing. A refused create is 409 CONVERSATIONS_SOMETHING_WENT_WRONG with empty arguments — it cannot tell a DND contact from a toll-free number from a duplicate group. [[no-silent-failures]] ⛔ NOTHING HERE INVENTS A TIME OR AN ADDRESS. {when} is rendered in the HOMEOWNER's timezone by b2c_post_booking.when_str, which drops the hour rather than guess when they have no zone — a homeowner told the wrong hour is simply not home. A template that still has an unfilled placeholder is refused, never sent half-built. Usage: python3 group_intro.py --self-test # preflight every active client, no writes python3 group_intro.py --location <loc> --contact <id> # dry run for one homeowner python3 group_intro.py --location <loc> --contact <id> --live cron */5 * * * * /usr/bin/flock -n /tmp/group_intro.lock /opt/ghl/run.sh groupintro group_intro.py --sweep --live >> /opt/ghl/logs/group_intro.log 2>&1 # Kirk 2026-09-08: every 5 min, not every 15. The delay is 20 minutes but the SWEEP was the real clock: a booking at :49 hit its 20m mark at :09 and then waited until the :18 pass, so a 20-minute promise was really 20-35. Five minutes makes it 20-25. flock stops a slow pass stacking. log /opt/ghl/logs/ · status /opt/ghl/status/groupintro.last | every 5 min, all day | 2 min ago | Sat 16:25 | OK |
put what each client has actually PAID us onto their KPI tab ridgerevfee · ridgerev_fee.py The roofer-facing panel carries "RidgeRev fee" and nothing on the sheet can know it: what a client has paid lives in Stripe. This fills it, and it is the one cell on that panel a formula does not own. What it does, in fullridgerev_fee.py — put what each client has actually PAID us onto their KPI tab. The roofer-facing panel carries "RidgeRev fee" and nothing on the sheet can know it: what a client has paid lives in Stripe. This fills it, and it is the one cell on that panel a formula does not own. ⛔ COLUMN A/B NOW, NOT H/I. The KPI tab was restacked vertically on 2026-09-25 — one column of labels read top to bottom instead of three blocks side by side. Because this finds its row by LABEL rather than by number, the move cost it one constant and nothing else. ⛔ FOUND BY LABEL, NEVER BY ROW NUMBER. The first version of this wrote to I11 because that is where the fee sat the day it was written. A warning banner went in at the top the following week, every row moved down two, and the money landed in a blank spacer while the real cell read "waiting on the numbers". Nothing failed. The write returned 200. [[client-tracker-sheets]] — a tracker number that is wrong still looks like a number. ⛔ amount_paid > 0 IS THE WHOLE FILTER. Every roofer also carries a second invoice with status "paid" at $0.00, so filtering on status alone counts two invoices each. And a paused subscription accrues nothing: Inspect-A-Deck reads "active" while carrying pause_collection keep_as_draft, with his 16 Sep invoice sitting unpaid in draft. This reports cash received, nothing else. ⛔ KEYED BY EMAIL. Not by name, which collides. Not by customer id, which splits Inspect-A-Deck in two — he has two customer records under one address. python3 ridgerev_fee.py # show what it would write python3 ridgerev_fee.py --live python3 ridgerev_fee.py --selftest cron 11 23 * * * /usr/bin/flock -n /tmp/ridgerev_fee.lock /opt/ghl/run.sh ridgerevfee ridgerev_fee.py --live >> /opt/ghl/logs/ridgerev_fee.log 2>&1 # what each client has actually PAID us, onto the KPI panel. Finds the row BY LABEL. Runs after cortanasheet (23:10) so the panel exists, before cycletracker (23:20). log /opt/ghl/logs/ · status /opt/ghl/status/ridgerevfee.last | daily at 11pm:11 | 17h ago | Sat 23:11 | OK |
read what was ACTUALLY said to a roofer and judge whether he was told noticewatch · notice_watch.py Kirk 2026-09-29, on being asked to type a date recording a message he had already sent: *"i dfont get this.. should you be the one saying this?"* — and he was right. He sent the message; making him record that he sent it is the same job twi What it does, in fullnotice_watch.py — read what was ACTUALLY said to a roofer and judge whether he was told. Kirk 2026-09-29, on being asked to type a date recording a message he had already sent: *"i dfont get this.. should you be the one saying this?"* — and he was right. He sent the message; making him record that he sent it is the same job twice, and forgetting to do it would block billing on a client he HAD told. ⛔ SO THE MACHINE READS WHAT HE SENT. Every RidgeRev-to-roofer text lives in the main sub-account, which is already where cycle_alert sends from and where the reply has to land. This walks that conversation for the cycle's own window and asks, of each outbound message, one question: does this tell him his cycle is ending or that a new one starts? ⛔ IT JUDGES THE WORDS, NOT THE SENDER. The first version of the notice gate decided by which script wrote the message — cycle_alert's heads-up "did not count" because that message is forbidden from naming money. But Kirk has since said NO PRICE in any client message at all, so by his own design a notice is money-silent. Which job sent it says nothing about what it said. [[judge-dont-match]] ⛔ IT GATES NOTHING YET, ON PURPOSE. Both live judges in this codebase were wrong on their first pass and only RUNNING them showed it. This prints its verdicts and the message each one is about, so Kirk can read them against what he knows he sent, before anything acts on them. [[verdicts-earn-their-actions]] ⛔ AND IT CANNOT SEE EVERYTHING. A message Kirk sent from his own phone, outside GHL, is invisible here. That is a real limit, not a bug to be engineered away, and it is why the typed 'Told him?' cell stays: his word overrides the judge, exactly as his date overrides the machine's extension. python3 notice_watch.py # judge and print, touching nothing python3 notice_watch.py --client Saber python3 notice_watch.py --record # ⛔ write the verdicts into the ledger (needs --live) python3 notice_watch.py --selftest cron 23 8 * * * /usr/bin/flock -n /tmp/notice_watch.lock /opt/ghl/run.sh noticewatch notice_watch.py >> /opt/ghl/logs/notice_watch.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/noticewatch.last | daily at 8am:23 | 7h ago | Sun 08:23 | 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 | 12h ago | Sun 03:50 | OK |
reads the verdict, sends the right message, and is the ONLY thing that can cyclenotice · cycle_notice.py make a cycle billable. What it does, in fullcycle_notice.py — reads the verdict, sends the right message, and is the ONLY thing that can make a cycle billable. ⛔ NOTHING BILLS WITHOUT A NOTICE. cycle_state refuses to charge for a cycle the client was never told about — Kirk 2026-09-27: *"don't charge Tom yet, he hasn't told me he wants to move onto cycle 2, or I have sent the notice."* So this file is the bottleneck by design: until a real notice goes out, no money moves for that cycle, ever. ⛔ AND RIGHT NOW NOT ONE OF THE THREE HAS HAD ONE. Read from the live ledger 2026-09-27: Inspect-A-Deck cycle 1 answered SKIP, Atlas cycle 1 answered SKIP, Saber cycle 1 never answered, Inspect-A-Deck cycle 2 nothing at all. All three paused in Stripe. No money is arriving from anyone. ⛔ IT REUSES cycle_alert's SEND PATH AND REBUILDS NONE OF IT. Civil hours in the CLIENT's timezone, the roofer hold, the pinned from-number so replies reach Kirk, send-then-read-back because a 201 is not a delivery, and the once-per-cycle ledger. Those took months of incidents to get right and there is exactly one of them. ⛔ SUPERVISED RELEASE. A client's FIRST notice of each kind still needs Kirk to type SEND on the hidden tab. Only once a kind has gone out correctly for that client does it become automatic. Kirk 2026-09-27 asked for send/skip to be automated — this is that, with the net kept for the first one. [[supervised-release]] python3 cycle_notice.py # what it would send, to whom, and why python3 cycle_notice.py --live --yes python3 cycle_notice.py --selftest cron 41 8 * * * /usr/bin/flock -n /tmp/cycle_notice.lock /opt/ghl/run.sh cyclenotice cycle_notice.py --live --yes >> /opt/ghl/logs/cycle_notice.log 2>&1 # the T-3 renewal notice and every state-change message (Kirk 2026-09-27). ⛔ NOTHING BILLS WITHOUT ONE — cycle_state refuses to charge for a cycle the client was never told about. First of each KIND per client still needs a typed SEND. 08:41 sits after cyclewatch 08:33 and before billing at 09. log /opt/ghl/logs/ · status /opt/ghl/status/cyclenotice.last | daily at 8am:41 | 7h ago | Sun 08:41 | OK |
rebuild photos.ridgerev.com from the Dropbox folder Kirk actually uses shotlist · shotlist_sync.py Kirk 2026-08-26: *"make it update automatically, by cron etc."* What it does, in fullshotlist_sync.py — rebuild photos.ridgerev.com from the Dropbox folder Kirk actually uses. Kirk 2026-08-26: *"make it update automatically, by cron etc."* ⛔ IT CANNOT SYNC FROM THE ARTIFACT, AND THAT IS WHY THE SOURCE MOVED. The server fetches the artifact URL and gets 200 with a 14 KB shell and ZERO images — the content loads behind auth in the browser. A cron pointed at the artifact would run green forever and publish an empty page. So the photos live in Dropbox, which the server genuinely can read (refresh token, any folder), and Kirk adds them from the Dropbox app on his phone instead of the artifact upload flow. Google Drive was the obvious first choice and does NOT work: this app holds `drive.file` scope, which only sees files the app itself created. A photo Kirk uploads is invisible to it — the failure would have been silent, and silent is the whole thing being avoided here. /Ad Shot List Photos/ 01 - you-on-the-roof/ <- any image file; the NUMBER is what matters 02 - you-on-the-ground/ ... 12 - in-progress-ground/ shotlist_sync.py # check + report, change nothing shotlist_sync.py --live # rebuild and deploy if anything changed shotlist_sync.py --selftest ⛔ Silent when nothing changed. It compares a content hash of the whole photo set, so a run that finds the same photos does not rebuild, does not redeploy, and does not speak. cron 33 8,20 * * * /usr/bin/flock -n /tmp/shotlist_sync.lock /opt/ghl/run.sh shotlist shotlist_sync.py --live >> /opt/ghl/logs/shotlist_sync.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/shotlist.last | 2x a day (8am-8pm) | 7h ago | Sat 20:33 | OK |
remind a new client about photos until they send them, then stop and say… photonudge · photo_nudge.py Kirk 2026-09-05: *"after the onboarding call … probably every day every two days leading up to the launch call, you would just send them a brief reminder reminding them about the photo if they had not already sent it. The moment they send i What it does, in fullphoto_nudge.py — remind a new client about photos until they send them, then stop and say thanks. Kirk 2026-09-05: *"after the onboarding call … probably every day every two days leading up to the launch call, you would just send them a brief reminder reminding them about the photo if they had not already sent it. The moment they send it then you turn that column to yes … don't follow up with them about pictures anymore … mix up the follow-ups each time, don't send the same thing over and over."* onboarding call ──► Pictures Sent? = No ──► nudge, every 2 days, at most 3 │ photos land in Slack ────────┤──► Pictures Sent? = Yes ──► one thank-you, (slack_photos flips it) │ then silence └──► 3 nudges unanswered ──► hand it to Kirk ⛔ A CLIENT IS ONLY EVER CHASED WHEN THE FIELD SAYS "No". Empty means nobody ever set it. On the day this was written the three ACTIVE clients already read Yes and the five reading empty were dormant or churned — Sunset had already left. Chasing "not Yes" would have texted five people we are not onboarding. See `client_pictures`, which owns that rule for both scripts. ⛔ THREE NUDGES, THEN IT STOPS AND TEXTS KIRK. Kirk's own ruling on the client-facing photo page was that stacked asks read as *"we're waiting on you · it never ends · the volume is your call"* — he paid to stop owning marketing and the page told him he still owned the part that recurs forever. The same posture is worse over SMS. A fourth unanswered reminder is a conversation Kirk should have, not a robot's job. [[project_ad_shot_list]] ⛔ EVERY REMINDER IS A REFRAME. The words never say "we need" — they say what WE are building. The ask appears once per message and is never counted ("send what you've got", never "send five"). ⛔ ONLY A VERIFIED SEND COUNTS. `send_and_verify` reads the message back; a 201 that never arrives must not burn one of the three, or a client gets one reminder and we think he got three. python3 photo_nudge.py --selftest python3 photo_nudge.py # who is due, what would be said — sends nothing python3 photo_nudge.py --live cron 28 10 * * * /usr/bin/flock -n /tmp/photo_nudge.lock /opt/ghl/run.sh photonudge photo_nudge.py --live >> /opt/ghl/logs/photo_nudge.log 2>&1 # remind a new client about job photos until they send them, then thank them and stop (Kirk 2026-09-05). Enrols a client the moment CS Status hits Onboarding, texts at most THREE times two days apart from the main RidgeRev number, and hands it to Kirk after that rather than nagging - his own ruling on the client photo page was that stacked asks read as "we're waiting on you". Only ever chases a client whose "Pictures Sent?" says No out loud; EMPTY is ignored, which is what keeps the churned clients out of it. Once daily is enough for a two-day cadence. Minute 28 runs ten minutes after slack_photos' :18 pass so a roofer who sent photos this morning is thanked, never nudged. log /opt/ghl/logs/ · status /opt/ghl/status/photonudge.last | daily at 10am:28 | 5h ago | Sun 10:28 | OK |
render every message a client's homeowners actually receive, into one clientmsgs · client_messages.py ClickUp cell, so Kirk can read the real words at a glance and fix them where they are wrong. What it does, in fullclient_messages.py — render every message a client's homeowners actually receive, into one ClickUp cell, so Kirk can read the real words at a glance and fix them where they are wrong. Kirk 2026-09-09: *"I told you I want to see all the messages that you have to customize in clickup columns as well so i CAN REVIEW for each client at a glance."* And, on finding Atlas's copy said "doing roofs around Jonesboro": *"see this is why i need these all in the clickup table so i can glance and adjust if need."* ⛔ RENDERED BY THE SENDERS THEMSELVES, NEVER RE-TYPED. Every string below comes from calling the same function that sends it — build_msgs, _messages, the group_followup ladder, BOOKED_ANSWERS. A second copy of the copy would drift from the real one within a week and would then be worse than no preview at all, because it would look authoritative while being wrong. ⛔ THE ⛔ MARKERS ARE THE POINT, NOT THE TEXT. Rendering is easy. Catching "doing roofs around for over years" — which shipped to Saber's homeowners for weeks — is the value. The senders' own guard is `re.findall(r"\{[a-z_]+\}", body)`, which only catches a LITERAL unsubstituted {placeholder}; an EMPTY substitution passes it clean. That is exactly how the gap survived. So this file checks the rendered OUTPUT for holes rather than the template for braces. ⛔ WHAT THIS CANNOT COVER, AND THE COLUMN MUST SAY SO. The first text and the booking confirmation live in GHL WORKFLOWS. No API can read a workflow body — GET /workflows/ returns id, name and status only, at every version, and there is no detail route. Those are the most-sent messages we have, and they are invisible here. Four Atlas homeowners were told "it's Jess from Inspect A Deck Roofing" for weeks and no preview column would have caught it. Saying so in the cell is the difference between a gap and a lie. python3 client_messages.py # print every client's card, write nothing python3 client_messages.py --client atlas # one client python3 client_messages.py --live # write into the ClickUp "Homeowner Messages" column cron 47 6 * * * /usr/bin/flock -n /tmp/client_messages.lock /opt/ghl/run.sh clientmsgs client_messages.py --live >> /opt/ghl/logs/client_messages.log 2>&1 # renders every homeowner-facing message per client into the ClickUp "Homeowner Messages" column so Kirk can read the real words at a glance (Kirk 2026-09-09, asked twice). 06:47: odd minute dodges */2 planner-watch, not /3 or /5, and 06 is outside the 8-20 zoomwatch window; nearest neighbours are 06:40 abtest and 07:00 auto-scrape. log /opt/ghl/logs/ · status /opt/ghl/status/clientmsgs.last | daily at 6am:47 | 9h ago | Sun 06:47 | OK |
run the selftests on a schedule and speak only when something REGRESSES selftestwatch · selftest_watch.py ⛔ WHY THIS EXISTS. On 2026-09-01 I changed the cancel path, the reschedule classifier, `future_appt` (six call sites read it) and the call ledger, all in one day, on a system three paying clients depend on. Every one of those changes was ve What it does, in fullselftest_watch.py — run the selftests on a schedule and speak only when something REGRESSES. ⛔ WHY THIS EXISTS. On 2026-09-01 I changed the cancel path, the reschedule classifier, `future_appt` (six call sites read it) and the call ledger, all in one day, on a system three paying clients depend on. Every one of those changes was verified thoroughly at the moment of shipping and NOTHING WOULD EVER LOOK AT THEM AGAIN. There was no regression net at all. ⛔ AND THE SELFTEST THAT EXISTED WAS PROOF OF THE PROBLEM. `dial_guard --selftest` had three failing cases for weeks and nobody knew, because nothing ran it. A test nobody runs is a comment. ⛔ IT PAGES ON A NEW FAILURE, NEVER ON VOLUME. A case that has always failed is not news; a case that passed yesterday and fails today is. An alert that fires every morning is an alert Kirk mutes, and a muted alert is how the three failures hid in the first place. [[no-silent-failures]] ⛔ ONLY THE ISOLATED SELFTEST RUNS HERE, AND THAT IS A DELIBERATE LINE. · `dial_guard --selftest` redirects its state to /tmp, drives a Flask test_client(), and stubs `_write_fields_by_phone` and `_client_locations` to no-ops. It touches no live account. Safe. · `appt_selftest.py` POSTs to /calendars/events/appointments and DELETEs real events on a LIVE client's calendar. Kirk 2026-09-02: it stays MANUAL. A cron that books appointments on a paying roofer's calendar is exactly how test traffic reaches a client, and that has already happened once. Run it by hand before shipping a calendar change. [[feedback_send_safety]] python3 selftest_watch.py # run and report, adopt nothing, page nobody python3 selftest_watch.py --live # run, page on regressions, update the baseline cron 25 6 * * * /opt/ghl/run.sh selftestwatch selftest_watch.py --live >> /opt/ghl/logs/selftest_watch.log 2>&1 # the regression net: runs dial_guard --selftest (fully isolated - /tmp state, Flask test_client, network stubbed) and pages ONLY when a case that used to PASS now FAILS. 220 cases. A case that has always failed is never news. appt_selftest is NOT here on purpose (Kirk 2026-09-02): it books and deletes REAL appointments on a live roofer calendar and stays manual. Minute 25 is ODD to dodge the */2 5-21 series; 6:25 is clear of 0 and 40. log /opt/ghl/logs/ · status /opt/ghl/status/selftestwatch.last | daily at 6am:25 | 9h ago | Sun 06:25 | 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 | Sat 16:24 | 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 | 22 min ago | Sat 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 | 12h ago | Sun 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 | 6d 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 | 7h ago | Sun 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 | 9h ago | Sun 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 | 7h ago | Sun 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 | 6d ago | Sun 05:00 | OK |
tell each roofer their cycle is coming to a close, without reframing the… cyclealert · cycle_alert.py Kirk 2026-08-25: *"more like a heads up that the cycle is coming to a close just based on the timing of the message, but do not let it cause an issue where they get reframed / reminded psychologically of how much they're paying. Every remin What it does, in fullcycle_alert.py — tell each roofer their cycle is coming to a close, without reframing the price. Kirk 2026-08-25: *"more like a heads up that the cycle is coming to a close just based on the timing of the message, but do not let it cause an issue where they get reframed / reminded psychologically of how much they're paying. Every reminder regarding money is a reframe."* ⛔ THAT CONSTRAINT IS THE WHOLE DESIGN, so it is written down rather than left to whoever edits this next. The obvious version of this message — "target was 10, you're at 4, cycle ends Sunday" — is not a status update. It is an invoice in disguise: it asks the client to price the relationship at the exact moment the number looks worst, and it arrives unprompted. A courtesy heads-up that does that is worse than no message at all. So: · NEVER put a target next to an actual. That pairing IS the reframe. · NEVER mention spend, cost, budget, invoice, billing or renewal. Not once, in any branch. · State a COUNT only when it is at or above pace — a good number is a reason to stay, a bad one unprompted is a reason to leave. When behind, say nothing about the number and point at the tracker, which they own and can open whenever they like. · Lead with what is NEXT, not with what is ending. ⛔ AND THE HONESTY LINE THAT GOES WITH IT. Not pushing a number is not the same as claiming a good one. Nothing here may ever say "on track", "ahead", "great progress" or anything like it unless the arithmetic says so — `pace()` decides, no adjective is ever hardcoded into the below-pace branch, and the full figures stay live on the client's own tracking sheet at all times. Selective emphasis in a courtesy text is normal account management. A false claim is not, and the difference is the only thing keeping this the right side of the line. cycle_alert.py # preview every message, send nothing cycle_alert.py --preview # the same, with the reasoning per client cycle_alert.py --live --yes # send ⛔ SHIPS DRY-RUN ONLY until Kirk approves the exact rendered text. feedback_send_safety: "Anything that triggers outbound messages requires Kirk's explicit go on the EXACT mechanism (verified behavior, not assumed) before touching real leads." This is a third outbound flow; the two he has authorised do not cover it. cron 33 10 * * * /usr/bin/flock -n /tmp/cycle_alert.lock /opt/ghl/run.sh cyclealert cycle_alert.py --live --yes >> /opt/ghl/logs/cycle_alert.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/cyclealert.last | daily at 10am:33 | 5h ago | Sun 10:33 | OK |
tell KIRK when a client is not on pace, while there is still time to fix… cyclewatch · cycle_watch.py Kirk 2026-08-25: *"a notification to me if a client should reach two weeks mark and they're not on track... not on pace to hit the target, send me an alert. Two weeks out, one week out."* What it does, in fullcycle_watch.py — tell KIRK when a client is not on pace, while there is still time to fix it. Kirk 2026-08-25: *"a notification to me if a client should reach two weeks mark and they're not on track... not on pace to hit the target, send me an alert. Two weeks out, one week out."* This is the mirror of `cycle_alert.py` and the pairing is the point: cycle_alert.py -> the CLIENT, near the end of the cycle, deliberately silent about the number when it is behind, because every reminder about performance at that moment is a reminder about price. cycle_watch.py -> KIRK, at checkpoints DURING the cycle, saying exactly how far behind and what it would take to recover. The client's message is calm because Kirk got the real one first and had time to act on it. If this script is not running, the other one is just a nice text about a bad cycle. ⛔ Both read the SAME numbers, via `cycle_alert.client_states()`. Two readers deriving the same figure separately is how they end up disagreeing about one client in one week. cycle_watch.py # what it would say, sends nothing cycle_watch.py --live # text Kirk about anything off pace cycle_watch.py --selftest ⛔ Silence means every client is on pace. It sends nothing when there is nothing wrong. cron 33 8 * * * /usr/bin/flock -n /tmp/cycle_watch.lock /opt/ghl/run.sh cyclewatch cycle_watch.py --live >> /opt/ghl/logs/cycle_watch.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/cyclewatch.last | daily at 8am:33 | 7h ago | Sun 08:33 | 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 | Sat 16:23 | OK |
text Kirk the moment a sales call is CANCELLED or MOVED, and say who did… salescallchanges · sales_call_changes.py Kirk 2026-10-20: "had a call today at 6pm with scott weaver, who cancelled? i didn't.. so how i didn't get this in gmail or anywhere????!" Scott cancelled at 5:16 PM CT from the cancel link in his confirmation email; GHL's alerts to Kirk we What it does, in fullsales_call_changes.py — text Kirk the moment a sales call is CANCELLED or MOVED, and say who did it. Kirk 2026-10-20: "had a call today at 6pm with scott weaver, who cancelled? i didn't.. so how i didn't get this in gmail or anywhere????!" Scott cancelled at 5:16 PM CT from the cancel link in his confirmation email; GHL's alerts to Kirk were switched off on every sales calendar, and nothing of ours watched for it. Kirk found out by sitting on an empty Zoom at 8. sales_calendar_alerts.py turned GHL's own email + in-app alerts back on. This is the second, independent path — a text, which he actually sees, with the one thing GHL's alert never says: WHO cancelled. WHO comes from GHL's audit log (services.leadconnectorhq.com/audit/search/v2, minted browser token — the public API has no "cancelled by"). subSource "booking_widget" = the prospect, from the link in his email. A GHL user = that user, by name. Unreadable = says so, never guesses. REASON is the appointment's appointmentMeta.cancellationReason, what they typed. Watches every sales calendar (show_tracker.GROWTH_CALENDARS) from yesterday to 60 days out. State is appointment id -> (status, start); the first run only records. A change to a call already in the past is ignored — a call that happened is not news. python3 sales_call_changes.py # check + text (cron) python3 sales_call_changes.py --dry-run # print what it would send, change no state cron 1-56/5 * * * * /usr/bin/flock -n /tmp/sales_call_changes.lock /opt/ghl/run.sh salescallchanges sales_call_changes.py >> /opt/ghl/logs/sales_call_changes.log 2>&1 # text Kirk the moment a sales call is CANCELLED or MOVED, and who did it (audit log) + the reason typed. Kirk 2026-10-20: Scott Weaver cancelled from his email link at 5:16pm and nobody told him. All hours: a cancel at night is still news by morning (notify holds overnight). Minute 1-56/5 is unused. log /opt/ghl/logs/ · status /opt/ghl/status/salescallchanges.last | every 5 min, all day | just now | Sat 16:26 | OK |
thank a roofer, once, when he leaves feedback on a lead fbthanks · feedback_thanks.py Kirk 2026-09-29: *"after roofer leaves feedback tell them thanks for their feedback on the lead line."* What it does, in fullfeedback_thanks.py — thank a roofer, once, when he leaves feedback on a lead. Kirk 2026-09-29: *"after roofer leaves feedback tell them thanks for their feedback on the lead line."* ⛔ IT READS WHAT submissions_log ALREADY WROTE DOWN. That job polls each roofer's disposition fields every 10 minutes and appends any change to his hidden `Submissions` tab. This reads that tab rather than polling GHL a second time — a second poller is a second answer about what he said, and the two would disagree inside the ten-minute blind spot submissions_log already documents. ⛔ SUPERVISED RELEASE, PER CLIENT. Kirk's choice. Until `ON` is typed in the switch cell on that client's `What happens next` tab, every thank-you is DRAFTED to Kirk and nothing is sent. Once armed they go by themselves. ⛔ Anything in that cell other than exactly `ON` is treated as unanswered and reported — a typo is not consent. [[supervised-release]] ⛔ ONE THANK-YOU PER PIECE OF FEEDBACK, EVER. Keyed on the exact row submissions_log appended, so a roofer who leaves three notes on one job gets thanked for the third, not three times for the first, and a re-read of the tab can never re-send. Thanking a man twice for the same sentence reads as a machine, which is the one thing this message exists not to be. ⛔ NO MONEY, checked at render time against cycle_alert.BANNED — the same list every other roofer-bound message is checked against. This is a courtesy, and a courtesy that mentions billing is not one. ⛔ AND IT IS NOT A REPLY IN THE GROUP THREAD. It goes on the RidgeRev 505 line, where every other RidgeRev-to-roofer message goes. Bots stay out of the group. [[feedback_group_thread_silence]] python3 feedback_thanks.py # DRY python3 feedback_thanks.py --live python3 feedback_thanks.py --selftest cron 1-59/30 * * * * /usr/bin/flock -n /tmp/feedback_thanks.lock /opt/ghl/run.sh fbthanks feedback_thanks.py --live >> /opt/ghl/logs/feedback_thanks.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/fbthanks.last | every 30 min, all day | 21 min ago | Sat 16:31 | OK |
the appointment ladder, run INSIDE the group thread, plus the two watches groupfollowup · group_followup.py WHAT THIS IS group_intro.py opens the thread ~2h after booking. This is everything after that: OUT reconfirmation at T-23h, a reminder at T-1h, a last one at T-15m IN every reply in that thread, classified, so a "yes" confirms and a "can we What it does, in fullgroup_followup.py — the appointment ladder, run INSIDE the group thread, plus the two watches. WHAT THIS IS group_intro.py opens the thread ~2h after booking. This is everything after that: OUT reconfirmation at T-23h, a reminder at T-1h, a last one at T-15m IN every reply in that thread, classified, so a "yes" confirms and a "can we move it" is not shouted into a void Copy is Kirk's OWN existing workflow copy, extracted verbatim from what those workflows actually sent homeowners over 45 days. It lives in config/group_followup.json and is not reworded here. Kirk 2026-09-07: *"analyze the past appointments to see the exact messges and just copy those."* ⛔ THE NATIVE WORKFLOWS MUST BE MUTED OR EVERY HOMEOWNER GETS ALL OF THIS TWICE. Kirk is muting them by hand, per sub-account. Nothing here can see whether he did — GHL's workflow API returns only id/name/status, never actions — so this cannot verify it and must not pretend to. If a homeowner reports duplicates, that is the first thing to check. ⛔ WHY A SEPARATE READER EXISTS AT ALL. /conversations/search NEVER returns a group thread, so reschedule_bot, appt_confirm_watch and reply_responder are all structurally blind to one — they all discover work through search. A reply in a group is invisible to every existing bot. This file is the only thing that can see it, which also means a bug here is silent by construction. Proven readable 2026-09-07: Kirk texted "Yo" into a group and it came back as inbound. ⛔ IT CAN ONLY SEE THREADS IT WAS TOLD ABOUT. Nothing can enumerate group conversations. The ids come from group_intro_opened.json and nowhere else. Lose that file and these threads are unreachable forever, by us and by every other script. It is load-bearing. Usage: python3 group_followup.py # dry run: what would send, what replies were seen python3 group_followup.py --live python3 group_followup.py --seed # mark current replies seen + ladder rungs done cron 13,28,43,58 6-21 * * * /usr/bin/flock -n /tmp/group_followup.lock /opt/ghl/run.sh groupfollowup group_followup.py --live >> /opt/ghl/logs/group_followup.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/groupfollowup.last | every 15 min, 6am-9pm | 9 min ago | Sat 16:28 | OK |
the archive, made readable adperf · ad_performance.py Kirk 2026-09-25, told the ad and cost data was safe in a zip: *"yeah ofcourse so how am i gonna read it?"* — which is the right question, because a preserved file nobody can open is only half the job. What it does, in fullad_performance.py — the archive, made readable. Which ad worked and what it cost. Kirk 2026-09-25, told the ad and cost data was safe in a zip: *"yeah ofcourse so how am i gonna read it?"* — which is the right question, because a preserved file nobody can open is only half the job. ⛔ IT READS THE DAILY STORE, NOT THE ARCHIVE AND NOT THE API. It used to read the archive's flat `attribution_by_ad.json`, and that carried a real fault: groupBy=ad merges by NAME, and 12 of Inspect-A-Deck's 47 ad names live in more than one campaign. So the sheet showed h1-3 at $794.08 when it is $817.20 split across campaigns — the same bug the page had until 2026-09-25. The daily store keys every row by (campaign, ad), so reading it fixes the sheet and guarantees the sheet and the page can never disagree: one store, two surfaces. [[caching]] [[sweep-the-principle]] ⛔ AND IT IS PRIVATE. Per-ad cost is KIRK'S unit economics, not a roofer's business: it shows what he pays for a lead and therefore what his margin is. It never goes on a client tracker, and this sheet is never link-shared. The client-facing numbers stay where they are, on their KPI tab. WHAT IT ANSWERS, which nothing else does today: which ad brought the cheapest lead and which ad brought none at all which ad's leads actually SHOW UP (cost per lead is not cost per appointment) whether an ad that worked in August stopped working in September ⛔ AN AD THAT SPENT MONEY AND PRODUCED NO LEADS IS THE MOST IMPORTANT ROW ON THE SHEET, and the naive arithmetic hides it: 0 leads makes cost-per-lead a divide by zero, which an IFERROR turns into a blank or a 0.00 that sorts to the top looking like the CHEAPEST ad. So a zero-lead row says "no leads" in words and is coloured red, and the sheet is sorted by SPEND, never by cost per lead. Same principle as the KPI panel's "no jobs closed yet". [[no-silent-failures]] ⛔ THE COST-PER COLUMNS ARE FORMULAS, NOT TYPED NUMBERS. Kirk can see the division and check it against the two cells beside it. A typed number is a number nobody can audit. [[derive-dont-hardcode]] python3 ad_performance.py # show what it would write python3 ad_performance.py --live python3 ad_performance.py --selftest cron 45 2 * * * /usr/bin/flock -n /tmp/ad_performance.lock /opt/ghl/run.sh adperf ad_performance.py --live >> /opt/ghl/logs/ad_performance.log 2>&1 # the archive made readable: which ad worked and what it cost, per client (Kirk 2026-09-25 "how am i gonna read it?"). Reads the ARCHIVE not the API, so it adds no Cortana calls and cannot drift from it. 02:45 is after cortana_archive at 02:20, which is its source. log /opt/ghl/logs/ · status /opt/ghl/status/adperf.last | daily at 2am:45 | 13h ago | Sun 02:45 | 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 | 14 min ago | Sat 16:38 | OK |
the Cortana replacement adspage · ads_page.py Kirk 2026-09-25: *"I want it to be like I'm using another cortana essentially.... like exactly everything.. But it doesn't lose data..."* — and, asked where it should live and how much it should cover: a **private web page**, replacing **th What it does, in fullads_page.py — the Cortana replacement. Reads the daily store, writes one self-contained page. Kirk 2026-09-25: *"I want it to be like I'm using another cortana essentially.... like exactly everything.. But it doesn't lose data..."* — and, asked where it should live and how much it should cover: a **private web page**, replacing **the attribution table**. ⛔ IT READS THE DAILY STORE, NEVER THE API. `cortana_daily.py` holds one row per client per day per grouping, so every date range Kirk picks is a SUM, computed in the browser. That is what makes an arbitrary range instant, what makes the page work with no internet, and what means the numbers survive Cortana losing them. [[caching]] ⛔ EVERY FIGURE IS SUMMED IN THE BROWSER FROM DAYS. Nothing is pre-aggregated into the page, because a pre-aggregated total can only answer the ranges someone thought of in advance — and Cortana's whole interface is an arbitrary range. ⛔ AN AD WITH SPEND AND NO LEADS IS THE MOST IMPORTANT ROW ON THE PAGE. Zero leads makes cost per lead a divide by zero; the obvious guard turns it into a 0.00 that sorts to the TOP looking like the cheapest ad Kirk ever ran. So it reads "no leads" in words, the row is tinted, and the default sort is SPEND. On the day this was built that was 85 ads and $1,054.57 — 23% of everything spent. ⛔ FOUR EVENTS, NOT FIFTEEN. Cortana has eleven more conversion types configured and INACTIVE. A replacement that shows all fifteen is not "exactly like Cortana", it is worse than it. ⛔ PRIVATE. Cost per lead is Kirk's margin. This never goes on a client tracker and never on ops.ridgerev.com, which has no authentication at all — only a noindex header, which stops crawlers and not people. ⛔ TWO HOMES, ONE PAGE. `--out` writes a file (published by hand as a private artifact); `--deploy` writes it to /var/www/ads on the server, where Caddy serves it behind a password and a nightly cron keeps it current. Kirk 2026-09-25 chose the nightly rebuild, because a published artifact cannot refresh itself and he should not have to ask for yesterday's numbers. ⛔ IT IS NEVER ops.ridgerev.com. That host has NO authentication — only a noindex header, which stops crawlers and not people. Cost per lead is Kirk's margin, and two other files in this repo already refused the same shortcut. python3 ads_page.py # report what it would build python3 ads_page.py --out page.html python3 ads_page.py --deploy # -> /var/www/ads/index.html, on the server python3 ads_page.py --selftest cron 50 2 * * * /usr/bin/flock -n /tmp/ads_page.lock /opt/ghl/run.sh adspage ads_page.py --deploy >> /opt/ghl/logs/ads_page.log 2>&1 # rebuild ads.ridgerev.com from the per-day store (Kirk 2026-09-25 chose the nightly rebuild over asking for a refresh). 02:50 runs after cortana_daily at 02:35 fills the store and ad_performance at 02:45. Reads the store only — no Cortana calls, so it cannot be rate limited. log /opt/ghl/logs/ · status /opt/ghl/status/adspage.last | daily at 2am:50 | 13h ago | Sun 02:50 | OK |
The hidden 'How this works' tab — every cell of a client's tracker, in… sheetguide · sheet_guide.py Kirk 2026-09-28, after opening the internal tab: *"ridgerev internal tab is so fucking hard to understand i can't... some columns not labelled.. some areas to fill i dont know what to fill or what it does... have like a sheet that all it do What it does, in fullThe hidden 'How this works' tab — every cell of a client's tracker, in plain words. Kirk 2026-09-28, after opening the internal tab: *"ridgerev internal tab is so fucking hard to understand i can't... some columns not labelled.. some areas to fill i dont know what to fill or what it does... have like a sheet that all it does it explain everything. a guide for those tabs"*, and then: *"Hidden guide covering everything in each client's sheet"*. ⛔ IT COVERS THE WHOLE FILE, NOT THE TAB HE WAS LOOKING AT. The guide that existed lived on the internal tab and explained the internal tab — seven cells out of the five tabs and roughly a hundred columns that actually decide what a client is owed. The settings that set the target, the start date and whether an appointment counts at all were on other tabs and appeared in no guide anywhere. ⛔ AND IT IS NOT WHERE THE WORK IS. Twenty-two rows of prose sat above the cycle table, pushing the thing he has to type in off the first screen; its last line collided with the table banner and had never once reached the sheet; and its own rows used the same labels as the transition box, which is what sent the morning score into the middle of it. A guide belongs beside the work, not on top of it. ⛔ EVERY HEADING IS DERIVED FROM THE CONSTANT THE WRITER USES, never retyped here. A guide that keeps its own copy of a column name is a guide that will describe a column that has been renamed. The entries below carry the MEANING, which no code can derive; the NAME always comes from the code. [[derive-dont-hardcode]] ⛔ AND IT CHECKS ITSELF. `--check` compares the guide against the live sheet in both directions: a column on the sheet with no entry is reported, and an entry for a column that does not exist is reported. A guide nobody verifies drifts into fiction, and a confidently wrong guide is worse than none — which is the whole reason he could not trust the tab. Runs nightly for every tracker, so a new client gets it without anybody remembering. cron 24 23 * * * /usr/bin/flock -n /tmp/sheet_guide.lock /opt/ghl/run.sh sheetguide sheet_guide.py --live >> /opt/ghl/logs/sheet_guide.log 2>&1 # the hidden "How this works" tab on every client tracker (Kirk 2026-09-28: "Hidden guide covering everything in each clients sheet"). Rebuilt nightly so it can never drift from the code it describes, and so a NEW client gets it without anybody remembering. 23:24 sits after cycle_tracker (:20) so the tabs it names already exist, and dodges the 7,22,37,52 automation_board series. log /opt/ghl/logs/ · status /opt/ghl/status/sheetguide.last | daily at 11pm:24 | 16h ago | Sat 23:24 | OK |
The hidden 'To send' tab — every message a roofer is owed right now,… tosend · to_send.py to tick when it has gone. What it does, in fullThe hidden 'To send' tab — every message a roofer is owed right now, ready to paste, with a box to tick when it has gone. Kirk 2026-09-28: *"i need to draft messages to send off to all 3 roofers in the tracking sheets to check off"*, and *"plus the pointers to talk about the switch from shows to handed off appts.. i need these in there for easy reach"*. ⛔ THE DRAFTS WERE SCATTERED AND ONE OF THEM DID NOT EXIST. Before this, Tom's texts were in a markdown file in the repo that no alert mentioned, the cycle heads-up existed only inside cycle_alert at the moment it fired, and `terms_reminder` had been asserting *"The draft is ready"* for weeks with **no draft anywhere**. He asked where they were and the honest answer was: three different places and one nowhere. ⛔ NOTHING HERE SENDS ANYTHING, EVER. It is a sheet of drafts and a tick box. Kirk 2026-09-19: *"dont send off any mesages without me btw"* — this is the opposite of a sender, and the tick is his record that HE sent it, never an instruction to this script. ⛔ A MESSAGE HE HAS TICKED IS FROZEN. Once the Sent? cell has anything in it, that row's text is never rewritten — the same rule the delivered count has. What he actually sent is the record, and a draft that silently improves itself after the fact destroys the one thing the tab is for. ⛔ EVERY MESSAGE COMES FROM THE COMPOSER THAT ALREADY OWNS IT — `terms_reminder.draft()`, `cycle_alert.compose()` — never a second copy written here. Two places that compose the same message will disagree, and the roofer gets whichever one happened to run. cron 26 23 * * * /usr/bin/flock -n /tmp/to_send.lock /opt/ghl/run.sh tosend to_send.py --live >> /opt/ghl/logs/to_send.log 2>&1 # the hidden "To send" tab: every message a roofer is owed right now, ready to paste, with a tick box (Kirk 2026-09-28: "i need to draft messages to send off to all 3 roofers in the tracking sheets to check off"). ⛔ NOTHING HERE SENDS — it is drafts and a tick. A ticked message is frozen and never rewritten. 23:26 sits after sheet_guide (:24) and dodges the 7,22,37,52 automation_board series. log /opt/ghl/logs/ · status /opt/ghl/status/tosend.last | daily at 11pm:26 | 16h ago | Sat 23:26 | OK |
the number homeowners are told stays the PLAIN number, for every client,… callingnumber · calling_number_guard.py Kirk 2026-10-22: "for the calling number we tell people return back to what we had for all before the last change.. this change is fucking up the results" — then "keep it this way going forward.... always...". What it does, in fullcalling_number_guard.py — the number homeowners are told stays the PLAIN number, for every client, always. Kirk 2026-10-22: "for the calling number we tell people return back to what we had for all before the last change.. this change is fucking up the results" — then "keep it this way going forward.... always...". So every client's `Calling Number` custom value must read exactly `918-359-7052`: ten digits, dashed, no '+1', no '(Tulsa, OK)', nothing after it. The location lives on in ClickUp as his data; it is not shown. The client list is DERIVED (active_clients + every GHL_CLIENT_* token), so a client added next month is checked the first day. Anything off is rewritten to the plain form of ITS OWN number — never a number from somewhere else — read back, and Kirk is texted what changed. A value that holds no ten-digit number at all is not guessed at: he is told. python3 calling_number_guard.py # report only python3 calling_number_guard.py --apply # fix + text (daily cron) python3 calling_number_guard.py --selftest cron 27 7-21 * * * /usr/bin/flock -n /tmp/calling_number_guard.lock /opt/ghl/run.sh callingnumber calling_number_guard.py --apply >> /opt/ghl/logs/calling_number_guard.log 2>&1 # STANDING RULE (Kirk 2026-10-22, "keep it this way going forward... always"): the number homeowners are told is the PLAIN GHL number, 918-359-7052 - no +1, no location. Rewrites any client that drifts, reads it back, texts Kirk. Hourly because the customizer can rewrite hourly. Minute 27 unused. log /opt/ghl/logs/ · status /opt/ghl/status/callingnumber.last | 15x a day (7am-9pm) | 55 min ago | Sat 16:27 | OK |
the ONE canonical way to text Kirk notifyflush · notify.py 5xx / 429 are common) and RETURNS whether it actually delivered, so an alert can NEVER be lost silently (Kirk 2026-07-22: a NEEDS-YOU alert vanished because the sender was a single POST that swallowed a timeout). Every alert path must go th What it does, in fullnotify.py — the ONE canonical way to text Kirk. RETRIES transient GHL failures (read-timeouts / 5xx / 429 are common) and RETURNS whether it actually delivered, so an alert can NEVER be lost silently (Kirk 2026-07-22: a NEEDS-YOU alert vanished because the sender was a single POST that swallowed a timeout). Every alert path must go through this. See skills: resilient-runs, no-silent-failures. from notify import notify_kirk if not notify_kirk(msg): # False = every retry failed → DON'T mark the work done; retry next run ... ⛔ KIRK'S OWN NUMBER CAN BE PUT ON DND BY GHL, AND THEN NOTHING CAN REACH HIM (2026-09-18). At 02:18 the "Batch 50 enrolled" text bounced — Twilio 30005, his handset unreachable for a moment — and GHL's auto-rule flagged HIS contact "DND: SMS active (TWILIO_ERROR_CODE: 30005)" exactly as it would a dead prospect number. From that second every alert from every automation (responder, dial queue, dispo, onboarding, appointments) came back `400 CONVERSATIONS_MSG_DND_ACTIVE_SMS` and this function returned False, correctly, forty times an hour, to nobody. Eight hours of alerts were lost — including "nobody has confirmed allen, inspection today 2 PM". Kirk: "why is my number on DND? how does that make sense?" It does not. His alert line is the one contact whose DND flag is never a real opt-out, so on that exact error this clears it — on HIS contact only, never a prospect's — says so in the log, and retries. [[no-silent-failures]] cron 1 5 * * * /usr/bin/flock -n /tmp/notify_flush.lock /opt/ghl/run.sh notifyflush notify.py --flush >> /opt/ghl/logs/notify_flush.log 2>&1 # release alerts quiet hours held overnight, as ONE text (Kirk 2026-09-22) log /opt/ghl/logs/ · status /opt/ghl/status/notifyflush.last | daily at 5am:01 | 11h ago | Sun 05:01 | OK |
the one piece of the Zip Targeting Factory that runs itself storm-watch · storm_watch.py Every other module in this folder answers a question Kirk asked it. This one asks its own question, every morning, on the DigitalOcean box: **did hail break roofs in a client's area yesterday, and is he advertising there?** A roof is bought What it does, in fullstorm_watch.py — the one piece of the Zip Targeting Factory that runs itself. Every other module in this folder answers a question Kirk asked it. This one asks its own question, every morning, on the DigitalOcean box: **did hail break roofs in a client's area yesterday, and is he advertising there?** A roof is bought when hail breaks it, and the window to be the roofer in front of that homeowner is days, not months. /opt/ghl/run.sh storm-watch storm_watch.py --live ⛔ IT LIVES ON THE SERVER, NOT THE MAC. The app is opened when Kirk feels like opening it; the weather does not wait for that. Running under `/opt/ghl/run.sh` also puts it under the two alert layers that already exist — run.sh texts on a non-zero exit or a Traceback, and monitor.py derives its watch-list from the crontab, so this file being scheduled is the same as this file being watched. [[no-silent-failures]] ⛔ NOTHING IS EVER WRITTEN TO META. This module reads NOAA, reads GHL, reads one Google Sheet, texts Kirk and writes two files under state/. That is the whole list. Kirk makes the call and does the Ads Manager paste himself. The selftest greps this source to prove there is no HTTP write in it at all. ────────────────────────────────────────────────────────────────────────────────────────────── WHAT IT DOES, EACH DAY, PER ACTIVE CLIENT ────────────────────────────────────────────────────────────────────────────────────────────── 1. Who is a client — `3 - Operations/scripts/active_clients.py` ACTIVE. ⛔ Not "every GHL_CLIENT_* token in .env": we still hold a working token for a client who left. 2. Their circle — centre + radius out of that sub-account's own custom values `Service Area Location` / `Maximum Mile Radius`, through `zip_overlap.client_areas()`, which is `dial_guard._client_service_area()` plus `zip_radius.geocode_centre()` and already says, by name, which half of a client's circle is missing. A client whose circle cannot be read is NAMED as COULD NOT CHECK on every run — never quietly skipped, because a skipped client looks exactly like a client with no storms. 3. The ZIPs in that circle — `zip_radius.zips_in_radius` over the Census ZCTA gazetteer. 4. Yesterday's hail — NOAA SWDI radar (`zip_storm.hail_in_box`), one request per client per day. 5. The bar, scaled to the roofs — `zip_storm.damage_threshold(row)`. 1.00" hail is the claimable size on an asphalt shingle and 50 kt (58 mph) is NOAA's severe-wind criterion, but a ZIP of 1965 roofs bruises at less than a ZIP of 2018 roofs, so the bar slides down to 0.75" in proportion to how worn that ZIP's owner-occupied stock is. The roof-age numbers come from the ACS cache this factory already holds. 6. What he is already running — the ZIPs on that client's own Google Sheet (written by `zip_out`), so the Mac and the server share ONE source of truth instead of syncing state between them. 7. If something qualifies — one text naming the client, the ZIPs, the hail size and date, and how many of those ZIPs his ads are NOT running in; and the same finding written to state/ so the app shows it at the top of that client next time he opens it. ────────────────────────────────────────────────────────────────────────────────────────────── ⛔ THE RULES THAT KEEP IT FROM CRYING WOLF [[feedback-alert-quality]] ────────────────────────────────────────────────────────────────────────────────────────────── • ONE TEXT PER STORM PER CLIENT, NEVER RE-SENT. The key is (client, local storm day) and it is persisted in `state/storm_watch_sent.json`. A storm that grows after it was reported updates the finding file and the log; it does not text again. • ⛔ ONLY COMPLETE DAYS ARE JUDGED, and that is what makes the rule above honest. If this ran on TODAY, a 6am hailstorm would be texted at 09:29 and the 5pm supercell on the same date would then be suppressed as "already sent" — the biggest storm of the day silently swallowed by the de-duplication. So the window is the last LOOKBACK_DAYS COMPLETE local days: yesterday is finished, and a morning text about last night's hail is still the same day's ad decision. • SILENCE WHEN NOTHING QUALIFIES. No daily all-clear. Kirk reads silence as healthy, and monitor.py is what notices a job that stopped running. • COULD NOT CHECK IS NEVER "NO STORMS". If NOAA cannot be reached, or a client's circle cannot be read, that client is printed as COULD NOT CHECK, by name, with the reason — and nothing about that client is scored. `zip_storm.hail_in_box` is all-or-nothing on purpose for the same reason: a score computed from 23 of 24 months looks exactly like one computed from 24. • A BLIP IS NOT AN OUTAGE. One failed NOAA call is weather-service weather. A client that has been unreadable for BLIND_DAYS consecutive days is a broken watch, and only then does this exit non-zero so run.sh's own alert fires. Reset by a single good day. • ⛔ AND ONLY A CLIENT *THIS RUN* FOUND BLIND CAN FAIL IT. Found in review: escalating on the count alone meant a client who LEFT kept a frozen `days == BLIND_DAYS` row and failed the run every morning forever, naming a company that is no longer a client — and a `--client X` run did the same to everybody else. `_escalates` now requires the row to be stamped today, and a row nothing has re-checked for BLIND_FORGET_DAYS is dropped and said out loud. ⛔ THE THREE THINGS THIS MODULE MUST NEVER DO QUIETLY (each one was a real defect, each one now has a selftest that fails if it comes back) • A TEXT THAT DID NOT GO FAILS THE RUN. `page()` returning False used to print COULD NOT PAGE KIRK and exit 0 — and run.sh only texts the last line when the exit is non-zero, so the one morning the pager broke produced a log line and silence. The retry tomorrow is a second chance, not a substitute for him hearing about it. • AN EMPTY AD LIST IS NOT AN ANSWER. A 'Running now' tab with a ZIP header and nothing under it is the same shape as a tab zip_out has not filled yet, a tab a human cleared, and a client who is paused. Read as "he is advertising nowhere" it printed "3 of them are NOT in the 0 ZIPs his ads run in now" — a confident zero built out of a tab that said nothing. It is COULD NOT CHECK, refused in `advertising_zips`, again in `usable_ads` at the point of use, and a third time in `not_advertised`, which raises rather than default to an empty set. • A REGISTRY THAT WILL NOT READ BREAKS THE ONE PROMISE. "One text per storm, never re-sent" is only true while `storm_watch_sent.json` can be read. An unreadable one used to read as "nothing has ever been sent" — every storm in the window texted again with nothing anywhere saying why. It is now named, it fails the run once, and the rewrite makes the next run clean. A file that simply does not exist yet is the normal first run and is not a failure. ────────────────────────────────────────────────────────────────────────────────────────────── MEASURED FACTS AND HONEST LIMITS — read these before trusting an output ────────────────────────────────────────────────────────────────────────────────────────────── • ⛔ THIS IS A HAIL WATCH, NOT A STORM WATCH. Radar-derived hail (SWDI `nx3hail`) is the only NOAA source that is current: the ground-report bulk CSVs lag about three months (measured 2026-09-25: the 2026 file stops at June), so wind and tornado CANNOT be checked daily by anybody, us included. `damage_threshold` still returns the wind bar and it is recorded in the finding, but no wind event can reach this file. Said here rather than left to be discovered. • ⛔ THE RADAR CACHE IS SEVEN DAYS OLD BY DESIGN, so this module fetches with `refresh=True`. `zip_storm.CACHE_TTL` is a week, which is right for ranking a market and fatal for a daily watch — a cached month written yesterday would answer "no hail" for today forever. The cost is one SWDI request per client per day (two at a month boundary): the chunker asks for whole calendar months because the API's ceiling is 744 hours and its cache key is the month. • ⛔ AND THAT IS WHY THIS FILE NEVER CALLS `zip_storm.fetch_chunk` DIRECTLY. Its cache filename is keyed on the month, not on the range asked for, so a caller that asked for one day would write a one-day answer into the month's cache slot and every later reader would believe that month was quiet. `hail_in_box` only ever asks for whole months, so going through it is the safe door. • The radar box is built exactly as `zip_storm.events_for` builds it — `bbox_around(points, MARKET_RADIUS_MI)` then `snap_bbox` — so the watch and the app share the same cached months rather than each pulling their own copy of the same weather. • ⛔ RADAR IS UTC AND HAS NO PLACE NAME, so the local day comes from longitude/15 (`zip_storm.local_day`). Real time zones are stepped and DST is not modelled, so a storm within an hour of local midnight can be filed on the neighbouring day. The window is two days wide and de-duplicated, which is what absorbs that. • ⛔ THE WATCH ONLY SEES ZIPs THAT ARE ZCTAs. It starts from the radius rather than from a paste, and a USPS ZIP with no Census area has no centroid, so it cannot be tested against a storm. ~19% of USPS ZIPs nationally; 3-8% of a real list. Those ZIPs are invisible here, by construction, and that is stated in the run output rather than implied by a clean-looking count. • ⛔ MEASURED ON THE FIRST LIVE DRY RUN, 2026-09-25: the watch is BLIND FOR INSPECT-A-DECK, and says so by name every run. Their GHL `Service Area Location` reads "Charlotte, North Carolina", and `zip_radius.split_city_state` only accepts a two-letter state, so the centre resolves to five Census places called Charlotte and `geocode_centre` refuses to pick one — correctly. That is a one-word fix in the sub-account's custom value ("Charlotte, NC") or a fix in zip_radius, which already downloads `reference/state.txt` and its STATE_NAME column. It is NOT this file's to patch around, and a client that cannot be placed is named, never skipped. • ⛔ THE FINDING FILE IS ON THE SERVER, and the app is on the Mac. `state/storm_watch_findings.json` is written where this runs, which is /opt/ghl/state — the app cannot read it over the network, so until something carries it across, the TEXT is what reaches Kirk and the finding is what the box holds for when it can. The ads list goes the other way and does not have this problem: it lives in a Google Sheet precisely so both machines can read the same copy. • ⛔ THE "NOT ADVERTISING THERE" COUNT IS BY ZIP CODE STRING, not by area. If he runs a ZIP that BORROWS this ZCTA's numbers (zip_zcta's BORROWED case), the codes differ and this counts the hit ZIP as one he is not running. It over-states rather than under-states — it shows him a ZIP to look at, never hides one — which is the right way round for an alert he acts on by hand. ⛔ PROVED ON A REAL STORM, NOT ONLY ON FIXTURES (Mac, dry run, 2026-09-25): `--client Atlas --replay 2026-02-26` read Atlas's circle live out of GHL (80 miles around Gilmore, AR), found 285 ZCTAs inside it, scaled the bar by roof age on 263 of them off the 2024 Census cache, pulled 362 radar hail rows from NOAA and produced EXACTLY ONE alert: 15 ZIPs over their own bar, biggest 3.00" in 38685, with the bar as low as 0.75" where the stock is oldest. That is the Tunica cell CONTRACT.md records from the other side — the same storm, found from the client's circle rather than from a point. The same command for 2026-09-23/24 found 0 rows and said nothing, which is the shape of a normal day. Flags: python3 storm_watch.py # dry run: print, touch nothing, text nobody python3 storm_watch.py --live # the cron line: text Kirk, write state python3 storm_watch.py --client Atlas # one client, by location id or name python3 storm_watch.py --replay 2026-04-14 # judge one past day, end to end python3 storm_watch.py --selftest # fixtures, no network, pager stubbed cron 37 4 * * * /usr/bin/flock -n /tmp/storm_watch.lock /opt/ghl/run.sh storm-watch storm_watch.py --live >> /opt/ghl/logs/storm_watch.log 2>&1 # a roof is bought when hail breaks it (Kirk 2026-09-25). Each active client: read his circle from GHL, ask NOAA radar what fell in it over the last 2 complete days, and text ONLY when hail beat that ZIP's own bar - 1in normally, as low as 0.75in where the owner homes are old. Silence means nothing qualified; NOAA unreachable says COULD NOT CHECK, never no-storms. One text per client per storm, never re-sent. 04:37 because 04:30 is monitor and nothing else runs until 05:00, which is 25 clear minutes for a job taking about 4 per client; an alert parks in quiet hours and releases at 05:00 anyway. flock because it must never overlap itself. log /opt/ghl/logs/ · status /opt/ghl/status/storm-watch.last | daily at 4am:37 | 11h ago | Sun 04:37 | OK |
the photos a roofer sends in Slack land in his own Dropbox folder slackphotos · slack_photos.py Kirk 2026-09-04: *"my roofer sending his like pictures of himself on the roof... so I can use it to run the ads."* And: *"sometimes they direct message me the pictures. Even though I tell them to send it in the group."* Both paths are cover What it does, in fullslack_photos.py — the photos a roofer sends in Slack land in his own Dropbox folder. Kirk 2026-09-04: *"my roofer sending his like pictures of himself on the roof... so I can use it to run the ads."* And: *"sometimes they direct message me the pictures. Even though I tell them to send it in the group."* Both paths are covered here; the DM path is the reason for half this file. WHERE THE PHOTOS GO, AND WHY THAT IS THE WHOLE JOB /Clients B2C Assets/<Client>/2.Photos/From Slack/ `creative_dropbox.list_photos()` already reads `2.Photos` RECURSIVELY, and `photo_registry` already identifies a photo by its DECODED PIXELS plus a perceptual hash. So a roofer re-sending a picture Kirk already has — recompressed by Slack or by his phone — reuses its existing number instead of minting a duplicate that would get tested twice as if it were a new creative. Everything downstream of "put the bytes in the right folder" is already built. This script's only job is the folder. ⛔ A USER TOKEN (xoxp), NOT A BOT TOKEN. A bot can only see conversations it has been added to, and it can NEVER see Kirk's direct messages with a roofer. Half the photos arrive by DM. A bot token here would work perfectly on the channels and silently miss every DM — the failure would look exactly like "that roofer doesn't send many photos". ⛔ A USER TOKEN SEES EVERYTHING KIRK SEES. Every private channel, every DM, his whole Slack. So the filter is a strict ALLOWLIST: a file is touched only if it positively resolves to an existing client folder. Nothing else is downloaded, uploaded, or written to state. This is why an unmatched file is dropped silently rather than parked — parking every screenshot anyone DMs him would page him daily and turn the alert into noise. Unmatched CLIENT-CHANNEL files are the exception and DO get reported. ⛔ NEVER GUESS WHICH CLIENT. Writing one roofer's photo into another roofer's folder puts the wrong house in the wrong man's ad. `client_dropbox.py` refuses to duplicate a client folder for exactly this reason. Matching here is a PREFIX match on the normalised name, minimum 4 characters, and it must hit exactly one folder. A tie or a near-miss resolves to nothing and gets reported — see `match_client`, which deliberately rejects a bare substring so a channel called #design cannot land in "Inspect-A-Deck Roofing Design & Construction". ⛔ SLACK ANSWERS 200 WITH AN HTML LOGIN PAGE when the download is unauthenticated. Not a 401 — a 302 carrying `x-slack-meta: ?;acl_fail` that requests follows silently to a sign-in page served as HTTP 200. Without the four checks in `download()` a 4 KB web page gets written into a paying client's photo folder named IMG_0412.jpg, and the only symptom is a creative that renders wrong weeks later. ⛔ NOTHING HERE RE-ENCODES A PHOTO. `url_private` is the master copy Slack stored; the bytes that reach Dropbox are the bytes the roofer sent, and the length is checked against Slack's own `size` so a truncated download is a failure rather than a slightly-broken JPEG. A `thumb_*` URL would be re-encoded and padded, quietly degrading every creative built from it — so it is never used. ⛔ KIRK'S OWN UPLOADS ARE SKIPPED. He is in every client channel; what he posts there is an example shot or a finished creative going the other way. Ingesting it would feed our own output back in as roofer source material. The token owner is resolved live from `auth.test`, never assumed. ⛔ THE FILENAME PREFIX IS NOT COSMETIC. `creative_dropbox.pull()` caches recursively into a FLAT local directory keyed on BASENAME, so `2.Photos/IMG_0397.jpg` and `2.Photos/From Slack/IMG_0397.jpg` would collide and one would silently overwrite the other. Every file lands as `<date>_<sender>_<original>`, which cannot collide with what is already there. Two roofers both shooting on iPhones produce colliding IMG_ numbers constantly. python3 slack_photos.py --selftest python3 slack_photos.py # dry run: shows the map and what WOULD move python3 slack_photos.py --live python3 slack_photos.py --backfill --live # everything in Slack history, once cron 18,48 * * * * /usr/bin/flock -n /tmp/slack_photos.lock /opt/ghl/run.sh slackphotos slack_photos.py --live >> /opt/ghl/logs/slack_photos.log 2>&1 # a roofer's Slack photos, from his channel OR a DM to Kirk, land in his own /Clients B2C Assets/<Client>/2.Photos/From Slack/ where creative_dropbox already reads them recursively (Kirk 2026-09-04). HELD until SLACK_USER_TOKEN is in .env and one supervised --backfill run is verified: with no token every run exits 1 and run.sh would page Kirk every 30 minutes. Minutes 18 and 48 are the only pair on this box free of every explicit single-minute job and every /5 /10 /15 /30 series. log /opt/ghl/logs/ · status /opt/ghl/status/slackphotos.last | every 30 min, all day | 4 min ago | Sat 16:48 | OK |
the roofer's disposition moves the opportunity to the matching stage dispopipeline · dispo_pipeline.py Kirk 2026-08-26: *"update like each of the dispositions excluding the Show and no show from the disposition column... you can use everything from column I to make decision. Except Show or no-show. The only place you should use Show and no s What it does, in fulldispo_pipeline.py — the roofer's disposition moves the opportunity to the matching stage. Kirk 2026-08-26: *"update like each of the dispositions excluding the Show and no show from the disposition column... you can use everything from column I to make decision. Except Show or no-show. The only place you should use Show and no show to make decision is column L... so like estimate in progress move it to estimate in progress pipeline stage, reschedule move it to reschedule pipeline stage, cancel likewise."* ⛔ TWO COLUMNS, TWO JOBS, AND THEY DO NOT OVERLAP. Appointment/Job Status (contact.appointmentjob_status) decides WHERE IN THE PIPELINE it goes. Show (Y/N) (contact.show_yn) decides ONLY whether they turned up. The job-status field carries "No-Show" as an option and it is IGNORED for that purpose here, because it is not trustworthy: Thomas Nahm's read ['No-Show','Estimate in Progress'] while the same submission recorded Show=Yes, Deal Size 30 squares, and a note about quoting his decks. It is a multi-select that keeps every value ever ticked. Reading it as "he no-showed" put a wrong text in front of his roofer. Show (Y/N) is the field that answers that question, and it is the only one allowed to. PRECEDENCE, deliberately in this order: 1. a real disposition wins — Rescheduled, Won, Lost, DQ, Estimate in Progress / Given / Sent 2. otherwise, Show = No sends it to the No Show stage 3. otherwise nothing moves So a homeowner who did not answer the door but has since been quoted lands on the quote, not on No Show. The later fact about the deal is the more useful one to the person reading the pipeline. ⛔ STAGES ARE RESOLVED BY NAME, never by id — every sub-account's copy has different ids, and a hardcoded id silently writes to the wrong stage or fails forever. [[derive-dont-hardcode]] python3 dispo_pipeline.py # show what would move, change nothing python3 dispo_pipeline.py --live python3 dispo_pipeline.py --live --seed # adopt what is already set, move nothing See skills: no-silent-failures, supervised-release, derive-dont-hardcode. cron 3-53/10 7-21 * * * /usr/bin/flock -n /tmp/dispo_pipeline.lock /opt/ghl/run.sh dispopipeline dispo_pipeline.py --live >> /opt/ghl/logs/dispo_pipeline.log 2>&1 # the roofer-s disposition moves the opportunity to the matching stage (Kirk 2026-08-26). Column I decides the stage, EXCEPT No-Show; column L Show(Y/N) is the only thing that decides showed-or-not. Minutes 20/50 dodge 10/35 cancelwatch and 7-52/15 reschedulebot. log /opt/ghl/logs/ · status /opt/ghl/status/dispopipeline.last | every 10 min, 7am-9pm | 8 min ago | Sat 16:23 | 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 | 6d 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 | 13h ago | Sun 02:30 | OK |
turn proof-deck beacons into what Kirk reads: who looked, how long, how… proofviews · proof_views.py Kirk 2026-10-24: "I need to track who views my social proofs (the results page / thank-you funnel) and how far they get in the deck ... proper tracking of all this so I can know." proof_app.py records one line per event in /opt/ghl/state/pr What it does, in fullproof_views.py — turn proof-deck beacons into what Kirk reads: who looked, how long, how far. Kirk 2026-10-24: "I need to track who views my social proofs (the results page / thank-you funnel) and how far they get in the deck ... proper tracking of all this so I can know." proof_app.py records one line per event in /opt/ghl/state/proof_events.jsonl; this script, every 10 minutes: 1. groups the events into VISITS (one viewer session each) and works out, per visit: when, how long the tab was actually in front of them, the furthest slide, every slide seen, time on each video slide, finished or not; 2. once a visit has gone quiet for 30 minutes, writes ONE note on that contact in GHL — in the account the link said (?l=), else the deck's home account (warm -> RidgeRev main, cold -> RidgeRev SMS 2); 3. rewrites Kirk's private "Proof views" sheet: People, Visits, and Slides (how many visitors reached each slide — where people drop off). ⛔ NO TAGS, NO FIELDS, NO TEXTS. A tag can start a GHL workflow nobody can read through the API; a note cannot. ⛔ A VISIT WITH NO CONTACT ID IS "unknown" — never matched to someone by IP or timing. ⛔ A note that did not write is retried next run, never marked done. python3 proof_views.py # notes + sheet python3 proof_views.py --dry-run # print, write nothing python3 proof_views.py --selftest cron 6-56/10 * * * * /usr/bin/flock -n /tmp/proof_views.lock /opt/ghl/run.sh proofviews proof_views.py >> /opt/ghl/logs/proof_views.log 2>&1 # who watched the proof decks, how far -> GHL note + private sheet; LIVE 2026-10-24 log /opt/ghl/logs/ · status /opt/ghl/status/proofviews.last | every 10 min, all day | 6 min ago | Sat 16:26 | OK |
Two nudges around a new roofer's first calls, both driven off RidgeRev… clientcalls · client_call_watch.py Kirk 2026-08-30: · *"send me a reminder 3 days before the launch call of a roofer from the main ridgerev account"* · *"around 5 days after onboarding call let roofer know via text message that all is progressing well and we're actively buil What it does, in fullTwo nudges around a new roofer's first calls, both driven off RidgeRev main's own calendars. Kirk 2026-08-30: · *"send me a reminder 3 days before the launch call of a roofer from the main ridgerev account"* · *"around 5 days after onboarding call let roofer know via text message that all is progressing well and we're actively building out everything, looking forward to getting everything started"* ⛔ THE CALENDARS ARE FOUND BY NAME, AND THERE ARE TWO OF EACH. RidgeRev main carries a `Launch Call` and an `Onboarding Call` calendar TWICE over — once as `round_robin` and once as `event` — and only the round_robin ones hold bookings; the `event` copies are empty. Hardcoding an id gets a 50% chance of watching a calendar nobody books on, forever, silently. So every calendar whose name matches is read and their events pooled. ⛔ CANCELLED APPOINTMENTS ARE NOT CALLS THAT HAPPENED. Atlas has a cancelled onboarding call on 23 Aug and the real one on the 24th; Saber has two. The LAST non-cancelled one is the call. ⛔ AND THE FIRST RUN MUST NOT TEXT ANYBODY. Every onboarding call already on the calendar is older than the window, so a first live run without --seed would send "we're building everything out" to three clients who onboarded weeks ago — one of them a month ago. The same mistake reschedule_bot made when it thanked a roofer for no-shows he had already dealt with. Seed first, always. python3 client_call_watch.py # dry: who is due, and the exact text python3 client_call_watch.py --seed --live # adopt everything existing, send nothing python3 client_call_watch.py --live # send cron 59 8 * * * /usr/bin/flock -n /tmp/client_call_watch.lock /opt/ghl/run.sh clientcalls client_call_watch.py --live >> /opt/ghl/logs/client_call_watch.log 2>&1 log /opt/ghl/logs/ · status /opt/ghl/status/clientcalls.last | daily at 8am:59 | 7h ago | Sun 08:59 | OK |
Watch the live A2P compliance sites and page Kirk the moment one drifts a2pwatch · a2p_watch.py The websites this box serves are invisible to monitor.py. That watchdog builds its list from `crontab -l`, so it only ever sees things that RUN — a Caddy vhost is not a cron job and has no row. Nothing on this machine would ever notice if r What it does, in fullWatch the live A2P compliance sites and page Kirk the moment one drifts. The websites this box serves are invisible to monitor.py. That watchdog builds its list from `crontab -l`, so it only ever sees things that RUN — a Caddy vhost is not a cron job and has no row. Nothing on this machine would ever notice if ridgerev.com started returning 502, if the privacy policy lost the one sentence carriers grep for, or if somebody dropped a contact form onto the declared opt-in page. That matters more here than for an ordinary page, because the URLs are written into a carrier registration. An approved campaign points at PrivacyPolicyUrl and TermsAndConditionsUrl forever; a 404 behind one of them is an approved campaign whose evidence has quietly gone. Every check is an ANONYMOUS fetch over the public internet — no cookies, no token, no local file read. The question is not "are the files on disk", it is "what does a carrier reviewer see", and those differ whenever DNS, Caddy, or a certificate is having a bad day. a2p_watch.py # check everything, print, alert on problems a2p_watch.py --site ridgerev # one site a2p_watch.py --quiet # cron mode: print, alert, never chatter a2p_watch.py --selftest ⛔ Silence means healthy. It sends NOTHING when every check passes, because a text that says OK every day is a text that stops being read, and the morning it says something else it gets skimmed like all the others. cron 33 6,22 * * * /usr/bin/flock -n /tmp/a2p_watch.lock /opt/ghl/run.sh a2pwatch a2p_watch.py --quiet >> /opt/ghl/logs/a2p_watch.log 2>&1 # the A2P compliance sites are on a carrier registration; nothing else on this box watches a static vhost (monitor.py is crontab-derived, so a Caddy site has no row) log /opt/ghl/logs/ · status /opt/ghl/status/a2pwatch.last | 2x a day (6am-10pm) | 9h ago | Sat 22:33 | OK |
what share of homeowner dials actually reach a person, per client pickuprate · pickup_rate.py Kirk 2026-10-11: *"not tracking pickup rates sadly"* — after a homeowner told him she does not answer unknown numbers. You cannot tell whether anything you change about caller ID works without this number, and it was sitting unused in Wavv' What it does, in fullpickup_rate.py — what share of homeowner dials actually reach a person, per client. Kirk 2026-10-11: *"not tracking pickup rates sadly"* — after a homeowner told him she does not answer unknown numbers. You cannot tell whether anything you change about caller ID works without this number, and it was sitting unused in Wavv's API the whole time. ⛔⛔ `answeredAt` IS NOT PICKUP, AND IT READS BEAUTIFULLY. Measured 2026-10-11 over 30 days: client calls answeredAt human Atlas 513 90.1% 12.7% Inspect-A-Deck 417 93.0% 22.8% Saber 195 89.7% 10.3% `answeredAt` is set when the call connects — **including when voicemail picks up**. Reported as a pickup rate it says 91% on a business where 42% of calls are logged No Answer and 22% Voice Message. The honest field is `human`, Wavv's answering-machine detection, and it says **16%**. A number that flatters by 5.7x is worse than no number. [[no-silent-failures]] ⛔ ONLY THE CLIENT GROUPS. Wavv's `subgroupId` is the GHL location id, and Kirk's B2B prospecting runs in its own groups off 35-37 rotating numbers. Counting every outbound call mixes the two. ⛔ IT WRITES B4:B6 AND NOTHING ELSE. b2c_call_tracker owns row 9 down; this owns three cells in the empty space under the title, so the two can never fight over a cell. python3 pickup_rate.py # print it, write nothing python3 pickup_rate.py --write # stamp it on each client's current month tab python3 pickup_rate.py --days 30 python3 pickup_rate.py --selftest cron 49 6 * * 1 /usr/bin/flock -n /tmp/pickup_rate.lock /opt/ghl/run.sh pickuprate pickup_rate.py --write >> /opt/ghl/logs/pickup_rate.log 2>&1 # what share of homeowner dials reach a PERSON, onto B4:B6 of each client's B2C call sheet (Kirk 2026-10-11: 'not tracking pickup rates sadly'). Mondays, because it is a trend not a daily number. Hour 6 is the only empty hour on this box and minute 49 is unused and dodges every /2 /3 /5 /15 /30 series. Writes B4:B6 ONLY - b2c_call_tracker owns row 9 down, so the two can never fight over a cell. Counts Wavv human=true, NOT answeredAt, which includes voicemail and reads 91% against a real 16%. log /opt/ghl/logs/ · status /opt/ghl/status/pickuprate.last | Mon 6am | 5d ago | Mon 06:49 | OK |
what the Anthropic bill is actually made of, by job and by model llmcost · llm_cost.py Kirk 2026-08-27: three separate explanations of a $57 day, all reasoned rather than measured, all wrong. This reads what llm_meter recorded and answers it with a number. What it does, in fullllm_cost.py — what the Anthropic bill is actually made of, by job and by model. Kirk 2026-08-27: three separate explanations of a $57 day, all reasoned rather than measured, all wrong. This reads what llm_meter recorded and answers it with a number. python3 llm_cost.py # today python3 llm_cost.py --days 7 python3 llm_cost.py --live # and text Kirk if a day is unusually expensive cron 17 6 * * * /opt/ghl/run.sh llmcost llm_cost.py --days 2 --live --sheet >> /opt/ghl/logs/llm_cost.log 2>&1 # what the Anthropic bill is made of, by job and by model (Kirk 2026-08-27, after a $57 day nobody could explain and three wrong explanations). Writes a row per day to the "RidgeRev - API Spend" sheet and texts Kirk if a day goes over $25. Minute 17 dodges every existing series; 6am so the previous day is complete. log /opt/ghl/logs/ · status /opt/ghl/status/llmcost.last | daily at 6am:17 | 10h ago | Sun 06:17 | OK |
when a cycle moves, the Stripe date moves with it billingsync · billing_sync.py ⛔ TWO CLOCKS, NEITHER AWARE OF THE OTHER, AND BOTH MOVE. Stripe bills on an anchor set when the subscription was created. cycle_tracker counts 28 days from a date on a sheet, and a cycle gets extended by hand whenever delivery runs long — I What it does, in fullbilling_sync.py — when a cycle moves, the Stripe date moves with it. Never silently. ⛔ TWO CLOCKS, NEITHER AWARE OF THE OTHER, AND BOTH MOVE. Stripe bills on an anchor set when the subscription was created. cycle_tracker counts 28 days from a date on a sheet, and a cycle gets extended by hand whenever delivery runs long — Inspect-A-Deck's cycle 1 was pushed twice. Stripe kept its own schedule through both. `roofer_payment_watch.cycle_drift()` has been reporting the gap for weeks and deliberately never corrected it, because "moving a billing anchor takes money off somebody on a date they did not agree to." This is the thing that corrects it, and the entire design is about making that safe. ⛔ APPROVAL IS TYPED IN THE SHEET, NEVER REPLIED TO A TEXT. Kirk 2026-09-27: *"I prefer to do it in the sheet. Give me the link in the text and tell me to update."* The script writes what it intends into column G of the hidden internal tab; Kirk types OK in column H; the next run applies it. A typo is not consent — anything that is not exactly OK or NO is unanswered and reported. ⛔ JOINED BY EMAIL, NEVER BY SHEET NAME. cycle_drift and ridgerev_fee match a client to a sheet with a 12-character fuzzy filename match and take hit[0]. That is tolerable for a report and unacceptable here: it is the line that would decide WHOSE billing date moves. Email is the only key the rest of the billing code trusts, for three recorded reasons — Inspect-A-Deck has two customer records under one email, Stripe holds cardholder names rather than business names, and a client whose email does not match is invisible and silently so. ⛔ A SEPARATE WRITE KEY. STRIPE_API_KEY stays read-only so the six scripts that only ever read cannot move money even with a bug in them. Only this file uses STRIPE_WRITE_KEY. ⛔ EVERY WRITE IS READ BACK. A money alert has no tell when it is wrong; a money write has less. ⛔ AND NOTHING BILLS WITHOUT A NOTICE. cycle_state gates on it: a cycle the client was never told about is not a cycle anyone agreed to pay for. Kirk 2026-09-27: "don't charge Tom yet, he hasn't told me he wants to move onto cycle 2, or I have sent the notice." python3 billing_sync.py # what it would propose, writing nothing python3 billing_sync.py --live # write proposals into the sheet, apply approved ones python3 billing_sync.py --selftest cron 46 8 * * * /usr/bin/flock -n /tmp/billing_sync.lock /opt/ghl/run.sh billingsync billing_sync.py --live >> /opt/ghl/logs/billing_sync.log 2>&1 # writes the Stripe proposal into column G of the internal tab and applies any Kirk has typed OK against. Runs AFTER cycle_notice so a cycle that just closed has its proposal in the same pass. log /opt/ghl/logs/ · status /opt/ghl/status/billingsync.last | daily at 8am:46 | 7h ago | Sun 08:46 | 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 | Sun 09:15 | OK |
Rebuilt every 15 minutes · this page last built Sat 24 Oct 2026, 16:22 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.