CANONICAL register of (a) every lane that SENDS or DRAFTS from
hello@themovementathlete.com, and (b) every scheduled job running on Aga's Mac. If a machine emails a member, drafts a reply, or refreshes a dashboard on a timer, it is listed here.Verified live: 24 Jul 2026 (launchd dumped, logs read, dry-runs executed, Gmail drafts enumerated). Owner seat:
tma-retention-lead(lanes) ·tma-coo-systems(the machine) · Aga approves every send.📅 DAILY TRACKING (added 6 Aug 2026, Aga's ask): the cockpit ⚙️ Daily Jobs tab now opens with a GENERATED tracking band — per-job ran-today (from the
tma_job_run.pysuccess stamps, not launchctl), the 🛟 member-facing-lanes-today table (sends · drafts · saves per lane, same sources as the 06:00 Slack digest), and a 14-day ran-grid. Generator:tools/ops/job_status_band.py, runs inside the 06:00 cockpit refresh; history:tools/ops/jobs_daily_track.csv. This register remains the editable prose source — it renders into the same tab; edit here and the cockpit follows at the next refresh.
tools/retention/HELLO_EMAIL_RULES.mdgoverns every 1:1 email sent from hello@. Set by Aga 31 Jul 2026. One sentence decides everything: it has to look like Jesse just typed it. No bold, no underline, no italics, no template, no images — readability comes from short paragraphs. Links are real clickable anchors in standard blue (never a bare URL to copy-paste), with CRO-reviewed, honest anchor text. This is NOT the marketing estate — the AC/CIO fleet rules (locked footer,email_lint, designed template) do not apply here and would destroy what makes these convert.
Nothing auto-sends to a member. Every member-facing message is created as a Gmail DRAFT in hello@ and waits for Aga. Three explicit exceptions: the two lanes armed
--live(R1 T-30 watcher, and the upgrade stages onceUPGRADE_LIVEis set) — both send templated, pre-approved copy, never a personal reply — and Aga's 14-hour rule (28 Jul 2026), under whichcom.tma.paymentautosendsends dunning-lane drafts older than 14h unlessneeds_aga()holds them. That third exception is scoped to the dunning lane's exact subjects + state file and cannot reach any other lane's drafts (audit-verified 31 Jul).
| Clickable links (31 Jul 2026) | Lane emails whose body contains a URL are now multipart/alternative: text/plain unchanged (bare URL — the fallback, and what needs_aga() reads) plus a minimal HTML twin where the URL becomes a real standard-blue anchor (#1155cc — deliberately not a brand colour; a normal blue link is what a normal personal email looks like). CRO-set anchor text, different per lane on purpose: dunning = "Fix your billing →" (task-framing — an existing member with a known fault; and critically it does NOT over-promise, because the destination is the Stripe login page, not a card form — "Update your card" would mis-set the first-screen expectation on a billing page, the worst place to create doubt); R1 cancel-save = "Keep training →" (outcome-framing, low-pressure — they chose to leave and the email's real goal is a reply; administrative language reads as the process they already rejected). Arrows kept: in a plain-text-style email they are the only visual cue the text is clickable. Future A/B: dunning "Restore your access →", R1 "Turn renewal back on →". Built by r1_lib.build_message() / text_to_html(). Deliberately NOT the marketing template — no logo, footer bar or background; these are 1:1 notes from Jesse and reading as a campaign would convert worse. Bodies with no link (failed-purchase, guardian alerts) stay pure plain text, untouched. Aga's trigger: "make a link an actual clickable link — like click here instead of a dead link they need to copy and paste." |
| Account | hello@themovementathlete.com (Google Workspace) |
| Auth | OAuth token at tools/Gmail Connection/token.json, auto-refreshed by r1_lib._creds() |
| From / Reply-To | The Movement Athlete <hello@themovementathlete.com> — set in tools/retention/r1_lib.py:34-35 |
| Why Gmail, not ActiveCampaign | Plain-text, personal, lands in the main inbox (not Promotions), and replies come back to a human inbox — the replies ARE the save |
| Why never AC-spoofed From | The domain is p=reject. A founder note spoofed through AC fails DMARC. |
| Shared engine | r1_lib.py (creds, Stripe pull, suppression, portal URLs) + check_replies.py (_gget / _gpost / _make_draft) |
Two scheduled jobs create drafts. Nothing else does.
com.tma.r1daily — 05:35 daily — the R1 reply assistant/opt/homebrew/bin/python3 tools/retention/r1_daily.py
Its heartbeat runs 5 steps; two of them create drafts:
| Step | Script | What it drafts |
|---|---|---|
| 2 | check_replies.py |
Walks hello@ for replies to R1 cancel-save emails → appends the reason to reason_log.csv → COMPOSES a genuine Jesse-voice reply to the member's ACTUAL email (local claude CLI, compose_reply() — Aga's 7 Aug 2026 rule after the Pablo Mendoza failure, where the keyword classifier answered only the price line of an 8-paragraph email). Guardrails: responds to every point, plain text, no invented facts/dates, only the standing 30%-off-renewal offer allowed, 60–180 words; output validated, and on ANY failure it falls back to the old PRICE/OTHER templates. Draft goes inside the same Gmail thread. STOP → suppressed, no draft. Bounces → member suppressed, no draft. |
| 2b | reply_fulfil.py |
Same mechanic for the lifecycle arcs that use "hit reply and I'll send it over" — 986 Welcome Lifetime Members + 976 Cancelled Win-Back. Both send from + reply-to hello@, so they land in the same inbox R1 watches. |
Other steps (no drafts): 0 exit-survey sync · 1 T-30 pre-expiry watcher (sends live) · 3 saves scoreboard · 3b upgrade engine · 3c upgrade Stage 2/3 · 4 r1_status.json · 5 cockpit <!--R1STATUS--> patch.
Idempotent via replies_state.json / reply_fulfil_state.json.
com.tma.paymentrecovery — 05:50 daily — the dunning lane/opt/homebrew/bin/python3 tools/retention/payment_recovery_lane.py --draft
Up to 3 personal plain-text notes to members whose card failed, timed off invoice age
(T1 ~1d · T2 ~4d · T3 ~8d; 10–21d = single final note; >21d = skipped, that's win-back
territory). Auto-exits the lane the moment the invoice is paid.
--draft = create drafts for review (current mode). --live = send directly (not armed).
Approved drafts are fired by the one-shot companion send_payment_drafts.py --send, which
hard-filters to this lane's exact subjects + payment_recovery_state.json, so it can never
touch an R1 reply draft.
The lane filters billing_reason == "subscription_cycle" (payment_recovery_lane.py:144).
That is a deliberate, load-bearing choice, and it means:
| Type of lead | Stripe shape | Covered? |
|---|---|---|
| 1. Existing member, renewal failed (card expired/declined) | subscription_cycle invoice, status=open, attempt_count ≥ 1 |
✅ Yes — T1/T2/T3 drafts, auto-exit on payment |
| 2. Purchase attempt died (new OR returning customer, card declined at checkout) | Failed charge with NO invoice — the sub goes incomplete, expires, invoice is voided not open |
🆕 failed_purchase_lane.py (built 24 Jul, drafts-only, not scheduled) |
| 3. Plan change/upgrade failed | subscription_update |
❌ No (1 open, 0 failed attempts — immaterial today) |
Why type 2 is invisible: a first-payment failure never produces an open invoice. Stripe
marks the subscription incomplete, then incomplete_expired, and voids the invoice. The lane
scans open invoices, so it can't see them — and neither can R1 (whose queue is
cancel_at_period_end=true).
Size of the gap (verified live 24 Jul, 90-day charge sweep): 524 failed charges — 486 invoice-backed (dunning lane's job) and 38 with no invoice, collapsing to 23 distinct people, of whom 9 are actionable inside 30 days ≈ $2,673 attempted. Closest existing machinery is the cart-abandonment build (756/757), which targets people who never reached payment — not people whose card was declined at it.
What they actually are: almost entirely $297 lifetime attempts declined by emerging-market issuers (BD · ET · NG · TZ · EC · LB) on a large single cross-border charge, plus a few high-intent repeat triers (jonaekstrom SE — 5 attempts across 2 cards; nurlomen07 BD — 5; fizan18 EC — 5, who eventually converted). The checkout is NOT broken — tested and refuted 24 Jul: in the same 4–8 Jul window 30 charges succeeded ($5,428.71) including $297 lifetimes from AU/ES/MY/GB/HK/US/CA, and Aga's own test card succeeded at 15:58 immediately after two of her cards declined at 15:54–15:57.
Open product question for Aga: for a declined emerging-market card, "try again" is weak. The useful offer is an alternative — a different card, PayPal, or stepping down to a monthly plan. That is an offer decision, not an email one.
failed_purchase_lane.py — the third lane (built 24 Jul 2026)Sibling of R1 (voluntary) and the dunning lane (involuntary renewal). Scans failed charges with no invoice, collapses to one row per person, and drafts ONE personal note — deliberately not a 3-part drip, because ~9 people/month does not justify one.
card_declined,
authentication_required) vs "the card details weren't accepted" (incorrect_number,
incorrect_cvc, expired_card). Never blame the wrong party.reply_fulfil.py (true since
31 Jul 2026 — audit finding C1 proved nothing watched these subjects before then).30d old (cold).
--draft · --live · --days N.
State failed_purchase_state.json · log failed_purchase_log.csv.com.tma.failedpurchase (05:55 daily, --draft), after
Aga's pre-flight: CRO copy review → full-family bug audit → documentation. See
JOB_AUDIT_FINDINGS_2026-07-31.md for the 30 findings and what was fixed.How this lane works (the operating description):
| Who it emails | People whose purchase attempt died at checkout — a failed charge with no invoice (Stripe marks the sub incomplete→incomplete_expired and voids the invoice, so neither R1 nor the dunning lane can see them). ~9 people/30d, ~$2.7K attempted, mostly $297 lifetimes declined by emerging-market issuers. |
| When | 05:55 daily, between dunning (05:50) and the cockpit refresh (06:00). |
| What it produces | ONE Gmail draft per person, ever — deliberately not a 3-part drip; ~9 people/month does not justify one. Drafts wait in hello@ for Aga. |
| Copy | Two variants chosen by decline code — bank-block (card_declined, authentication_required, insufficient_funds, do_not_honor) vs card-details (incorrect_number, incorrect_cvc, expired_card). Never blames the buyer for a bank block or the bank for a typo. 3+ attempts adds an apology line. CRO-reviewed 31 Jul: "cross-border charge" (names the real mechanism for the BD/NG/ET audience), "call your bank to approve it" (the British "give your bank a nudge" didn't travel). Load-bearing element per the review: the dual exit — "if you still want in, reply" carries equal weight to "if the timing was wrong, reply and say so" — which is what makes it read as service, not chasing. |
| Conversion mechanic | A reply. No links in the body at all (nothing for spam filters or the needs-Aga gate to trip on). Jesse then sends a fresh link personally. |
| Skips | founder's own card tests · deny-list · suppress.csv · anyone in the R1 cancel queue · anyone the dunning lane owns · anyone who EVER paid successfully (paginated + fails safe: a Stripe error counts as paid) · <12h old (let Stripe's retry play out) · >30d old (cold) · pending charges. |
| Can it auto-send? | No. send_payment_drafts --auto matches on exact equality with the dunning subjects + its own state file; this lane's subject and state differ. Guarded by an import-time assert so a future copy edit can't silently break it. |
| Replies + STOP | Watched since 31 Jul (audit C1): STOP → suppress.csv automatically; a live reply is surfaced as 🔴 NEEDS AGA in the daily run and counted into r1_status.json. No auto-draft — a drafted "here's your link" with no link is worse than nothing. |
| State / log | failed_purchase_state.json (atomic, saved after every draft) · failed_purchase_log.csv |
| Reaches Aga | 06:00 Slack post + 07:00 cockpit email both carry a 🛟 Retention rail — hello@ block, rendered from ONE shared source (tools/retention/retention_digest.py) so the two can never disagree. Headline is what NEEDS her: drafts waiting (live Gmail count, so carry-over from previous days is included) and 🔴 live payment replies; R1 queue and failed-purchase recovery follow as context. Best-effort by construction — any failure renders empty and the money post is untouched. |
| Daily read | Writes failed_purchase_status.json every run, in every mode (drafted today · people seen in window · touched all-time · recovered = paid after we wrote, the outcome metric · attempted $), then patches the cockpit's <!--FPSTATUS--> block in the 🛟 Retention · R1 tab. It does NOT deploy — the 06:00 refresh (5 min later) does, same as r1_daily. Added 31 Jul after Aga asked where the daily analytics sat: the lane had been armed with documentation but no daily read, the exact drift this estate keeps getting bitten by. |
| Modes | dry (default) · --draft (scheduled) · --live (not armed) · --days N |
Why parked: a new set of checkouts is being built, and they will feed richer information through Stripe. Rather than tune a lane against today's thin signal, we wait and build against the better one. The existing lanes (R1 + dunning) keep running unchanged in the meantime.
🔴 ASKED OF NIC — sent 24 Jul 2026, awaiting confirmation. Do this AT BUILD TIME, not as a
retrofit: the new checkouts must write
plan_slug · plan_id · coupon · user_id · buyer_email into PaymentIntent metadata.
Today's PIs carry an empty metadata object, which is the single reason plan and coupon have to
be inferred from the charge amount. Baking it in while the checkouts are being written costs
nothing; retrofitting it later costs a Nic ticket. Same hook as cart-abandon Path B — one change
serves the cart-abandon bridge, this lane, and Stripe→app user-id attribution.
Un-park checklist: new checkouts live ✅ (checkouts-v3, verified 30 Jul) → confirm PI
metadata is populated ✅ CLOSED by Nic, verified 30 Jul PM (the completed v3 trial's intent
carries buyer_email + user_id, its customer carries email/name/django_user_id/consent_at
— stamped at pay-click; the 24 Jul ask is DONE for everyone who clicks pay. ⚠️ blur-stamp
persistence for abandoners still unconfirmed, and the v3 flow mints phantom subs at page load
= noise in any scan — both on Task 1, tma-nic-attribution-brief.netlify.app) → re-run
--days 30 dry → Aga approves the copy → --draft → read → arm.
com.tma.quizabandon — every 30 min — quiz-checkout abandonment sync (NEW 3 Aug 2026)tma_job_run.py → quiz_abandon_sync.py --sync
The bridge that ended the "silent doorbell" (756/757 Abandoned Cart Reminder active since Oct
2025 with entered: 0). Reads unpaid quiz-checkout subscriptions from Stripe (metadata:
email · persona · offer · plan) → writes AC fields TMA Quiz Persona / Offer / Plan + tag
quiz-checkout-abandoned. Sync mode emails NOBODY. Enrolment (756 in-window E1–E3 →
757 day-4 E4) requires BOTH the QUIZ_ABANDON_ENROL_ARMED sentinel (Aga creates to arm,
deletes to disarm — the UPGRADE_LIVE pattern) and the job being switched to --enrol.
| Who | Paywall VIEWERS who declined (intent is created on checkout page LOAD — they are NOT card-abandoners; copy law: never "you almost paid"). ~10/day, honest recovery sizing $900–1,500/mo at the 3–5% benchmark. |
| Safety | Buyer-check (paginated, fails-safe) at sync AND before EVERY enrolment stage — a buyer can never enter (NIC-21). Suppression honoured. On 756 entry the contact is pulled out of 750/751 (frequency ceiling). Per-person atomic state writes. |
| Sequence | E1 ≤60min mirror-their-plan · E2 +22h objection+guarantee · E3 +40h honest $97-window expiry (the REAL Django-served deadline — no fake revives) · E4 day-4 quarterly $49.97 STANDARD safety net (Aga ruling 3 Aug — no coupon; "$49.97 ≈ half of $97" IS the frame). Exit after day 4 → SPEC-REENGAGEMENT. |
| Owner | .claude/agents/tma-quiz-checkout-abandon-owner.md (doctrine: SPEC-CART-ABANDONMENT). Copy: /email-fleet only, slug quiz-checkout-abandon-2026-08. |
| State / status | quiz_abandon_state.json · quiz_abandon_status.json → cockpit <!--QZABANDON--> (Retention tab) + retention digest (Slack 06:00 / 07:00 email). |
| QA gate | docs/company/GOVERNANCE/QUIZ_ABANDON_QA_2026-08.md must be green before the sentinel is ever created. |
Recovery runs entirely on the address Stripe holds. If that address is wrong, Stripe's own dunning emails and ours both go nowhere, and the invoice just ages out.
Live example (24 Jul): invoice in_1TwYSDI6s6pBAbnmTgXXQivY, $157 Yearly V3 renewal, carries
ryanlion2010@ico**u**d.com — a missing l. The same member exists correctly as
cus_SgECxPLlzALrAn / ryanlion2010@icloud.com, and the app has the correct address (that's
what the failure notification quoted). Two customer records, one typo, zero deliverable
recovery mail. Fix = correct the email on the failing customer record in the Stripe Dashboard
(the repo's Stripe keys are read-only by design), before the T1 draft is sent.
Worth a periodic sweep: any lane email whose recipient bounces should be reconciled against the app's own address for that user id.
| Script | Trigger | Sends |
|---|---|---|
send_r1.py |
manual, per segment | The R1 cancel-queue save note (A ≤30d gets the offer; B/C get the question only) |
pre_expiry_watch.py --live |
inside r1_daily step 1 |
Auto-note the day a cancelled sub enters the 30-day window |
send_upgrade_stage.py |
inside r1_daily step 3c |
Monthly→Annual Stage 2 (day ≥17, human touch) + Stage 3 (day ≥27, $129/yr, 7-day deadline). DRY until the sentinel file tools/retention/UPGRADE_LIVE exists. |
send_concierge.py |
manual | 1:1 concierge note to ACTIVE monthly members. Dry unless --live. |
tools/finance/daily_cockpit_email.py |
com.tma.cockpitemail 05:55 (was written here as 07:00 — WRONG since the 6 Aug morning-chain retime; the plist has said 05:55 all along, corrected 11 Aug 2026) |
The daily cockpit brief — to hello@, internal only. Rebuilt 25 Jul: leads with repeatable net cash → $15K (MRR demoted to secondary), reads kpi_15k.json/data_flow_daily.json from the 06:00 run and degrades gracefully if absent |
~/Library/LaunchAgents/)| Job | When | Runs | Touches hello@ |
|---|---|---|---|
com.tma.r1daily |
05:35 daily | retention/r1_daily.py |
✅ drafts + live T-30 sends |
com.tma.paymentrecovery |
05:50 daily | retention/payment_recovery_lane.py --draft |
✅ drafts |
com.tma.paymentautosend |
20:00 + 08:00 daily | retention/send_payment_drafts.py --auto — 🔴 the only job that SENDS member email with no human in the loop. Aga's 14-hour rule (28 Jul 2026): a lane draft older than 14h sends itself. Absolute exception: needs_aga() HOLDS anything carrying a link outside the allowlist, a checkout, a discount/promo code, an offer, or an unfilled placeholder — those wait for Aga indefinitely. Logs auto-sent to payment_recovery_log.csv; logs /tmp/tma_payment_autosend.* |
✅ SENDS |
com.tma.cockpitrefresh |
05:25 daily | finance/refresh_cockpit_daily.py — 🔴 11 Aug 2026: two ordering bugs closed. It now runs seo/lead_lanes_refresh.py as its FIRST 0d-quater step, because the GA4 lead-origin table was written only by seodailysync at 05:35 — ten minutes after this chain embedded it, so Aga's "where leads come from" table was a full day stale EVERY day (measured: kpi generated_at 05:28 carrying ga4_lanes pulled the previous date). It also runs finance/sales_yesterday_pull.py before the Slack post. Both are policed by finance/tracker_freshness.py, whose AGE and CHAIN-COHERENCE checks fail loudly on Aga's own screen if either regresses |
— (deploys cockpit; since 25 Jul also runs flow_metrics_pull.py → step 0e and kpi_15k_sync.py → step 4a, which own the 🎯 $15K tab. 4a must stay AFTER canonical_facts.json is written. It also posts to Slack — that lane is NOT gated by --no-deploy) |
com.tma.emailmetrics |
06:10 daily | email-analytics/collect_metrics.py |
— |
com.tma.workout1 |
03:15 daily · PLIST WRITTEN, NOT YET LOADED (arming is Aga's, with the AC automation) | retention/workout1_moment.py --apply — THE WORKOUT-1 MOMENT lane. Reads Intercom workouts_finished_number for every 90d-active contact, diffs against last night's snapshot (workout1_state.json), and tags everyone who went 0 → ≥1 since the last run with workout1-yesterday (an AC automation starts on that tag, waits until ~8am, sends one email, ends — AC never has to see the app) plus a permanent workout1-done (the welcome arcs' skip-condition, so an active trainee stops being asked "have you started yet?"). Payer-excluded FOUR ways: Intercom plan prefix · live Stripe both accounts · the EVER-PAID set (lifetime is a one-time CHARGE) · the segment gate before any send. 🔴 First run SEEDS ONLY and refuses to tag — with no prior snapshot every existing member looks like a first-timer; this is the absence of data, not an overridable flag. 🔴 Refuses outright above MAX_COHORT=400/day — that means a stale snapshot or an Intercom backfill, not 400 first workouts. Snapshot advances ONLY after a successful tagging pass, so a crash re-runs the same diff rather than losing the cohort. Why a count diff and not a timestamp: Intercom exposes no last_workout_at (measured 11 Aug across all 76 attributes). Health: workout1_moment.py --health. Seeded 11 Aug 2026 with 15,410 contacts. Doctrine: WELCOME_CTA_DOCTRINE_2026-08-10.md §0 — this is one of the two legal trial asks |
— (writes AC tags; sends nothing) |
com.tma.funnelstage |
02:30 daily | retention/funnel_stage_export.py --apply — stamps TMA_FUNNEL_STAGE (survey_unfinished/no_workout/no_trial) + TMA_FUNNEL_STAGE_AT onto EXISTING AC contacts from Intercom funnel state (90d-active, payer-excluded via plan-prefix + live Stripe cross-check; segment D = abandon-fleet's, never stamped; never creates a contact). Changes-only via funnel_stage_state.json; blind-crawl canary aborts exit 2; clears the stage the day a person converts (stale-label hole closed 7 Aug); self-verifies 8 read-backs per run (non-zero exit → guardian alerts); runs via tma_job_run.py so com.tma.jobguardian kickstarts it after a missed window and alerts on failure. Health: funnel_stage_export.py --health. 🔴 Send gate: any send on these segments needs segment_guard.py PASS (live re-pull + every-email Stripe check + freshness), per Audience Data Dictionary rule 5. Armed 7 Aug 2026 on Aga's D2 GO — master: docs/company/ANALYTICS/AUDIENCE_INTEGRITY_MASTER_2026-08-07.md |
— (writes AC fields; sends nothing) |
com.tma.morningpulls |
05:00 daily | finance/morning_pulls.py |
— |
com.tma.cockpitwatchdog |
06:30 daily | finance/cockpit_watchdog.py — 14 checks (was 13). 🔴 Added 7 Aug 2026: ac-automations → retention/ac_automation_state_check.py, which snapshots every ActiveCampaign automation's status and alerts when one silently changes state. Born the day 750 TMA | Welcome Sequence After Quiz was paused 16:41 and un-paused 18:03 with no replacement arc — 82 minutes in which a new quiz lead got two transactional emails and then nothing, found only by accident because a naming-rule lookup printed the live status. Read-only (GETs only; it cannot pause or resume anything). Exit 2 = UNDETERMINED, not a pass — it refuses to report "no changes" from an empty or truncated fetch. Deliberately not in HEALS: a state change needs a human decision, never a self-heal. |
— (alerts) |
com.tma.socialdash |
06:30 daily | social-pipeline/refresh_social_dashboard.py (venv python; fixed 24 Jul — was .sh via /bin/bash, exit 126 every run). Extended 3 Aug: step 1/4 now pulls Instagram per-reel insights (ig_insights.py), deliberately non-fatal so a bad IG day cannot take the board down |
— |
com.tma.socialkpislack |
06:45 daily | social-pipeline/slack_daily_kpi.py --post — posts the daily social KPI digest to Slack #social-fleet-kpis-updated via SLACK_SOCIAL_WEBHOOK in .env.local. Reads ONLY social_status.json, so it can never disagree with the board; runs 15 min behind socialdash so it always reads fresh data. 🔴 Never quotes GA4's raw YouTube session count (crawler artefact) — uses live.youtube_traffic.real_28d_equivalent |
💬 posts to Slack (no member email) |
com.tma.seodailysync |
05:35 daily | seo/seo_daily_sync.py --deploy (fixed 24 Jul — logs were on T9, spawn failed exit 78; now /tmp/tma_seo_sync.*). Extended 24 Jul: also writes the zero-LLM marketing/seo/SEO_DAILY_BRIEF.md (5 fleet KPIs + AGA/NIC ≤3 human lists from marketing/seo/SEO_HUMAN_QUEUE.json) and mirrors the human lists into the cockpit SEO-LIVE block |
— |
com.tma.pmbrief |
05:45 daily | ops/pm_board.py brief --write — the PM fleet's morning CEO brief → marketing/seo/PM_MORNING_BRIEF.md: what ran without Aga (24h ledger), what needs her (incl. Nic rows awaiting --nic-approved), her ranked top 3, open load per pod, and the integrity flags. Reads all five pod queues through aga_console_sync's own extractors so it can never disagree with the cockpit. 🔴 Runs after seodailysync (05:35) so the queue lists are already fresh, and before Aga's 06:00 start — the whole morning chain was moved earlier on 6 Aug 2026 because it had been building her lists at 06:40, after she sat down. Deterministic only; the judgement layer (the ONE cash constraint) comes from /pm-fleet in session |
— |
com.tma.socialselflearn |
06:35 Mon | social-pipeline/social_selflearn.py — the weekly organic-social self-learning loop (reads social_status.json, adjudicates the week's hook/format experiments, refreshes the board). Row added 10 Aug 2026 — it had been running unregistered since 5 Aug and was found by the guardian's new register-drift check on that check's very first pass. |
— |
com.tma.seoselflearn |
08:45 Mon | seo/seo_selflearn.py (venv python; logs /tmp/tma_seo_selflearn.*) — zero-LLM weekly loop: adjudicates the 14-day GSC watches on every fleet-shipped page (KEEP / WATCHING / REVERT-FLAG — flags only, never auto-reverts), writes the CMO/CEO weekly to marketing/seo/reports/SEO_WEEKLY_REPORT_<date>.md, re-ranks SEO_HUMAN_QUEUE.json, flags the monthly AI-citation panel when due |
— |
com.tma.cockpitemail |
05:55 daily | finance/daily_cockpit_email.py |
✉️ sends to hello@ |
com.tma.mondaybrief |
07:00 Mon | finance/monday_brief.py |
✉️ internal |
com.tma.emailselflearn |
08:12 Mon | email-analytics/run_selflearn.py |
— |
com.tma.forgeassets |
07:40 Mon | forge-assets/run_weekly.py (homebrew python; logs ~/Library/Logs/tma/com.tma.forgeassets.*) — refreshes the FORGE asset DB: catalog → Drive ingest → vision (new content-hashes only, so a quiet week is nearly free) → manifest → verify_forge_db.py gate. 🔴 Written in Python, not bash — tma_job_run.py is explicit that system binaries cannot touch /Volumes/T9 under launchd (§6). 🔴 Logs every run to the fleet ledger including no-ops, and shouts if the Drive source was skipped — an unreachable source is ABSENT, not empty |
— |
~~com.tma.ytretrofit~~ |
RETIRED 10 Aug 2026 | was youtube-analytics/apply_retrofit_all.py --apply — the retrofit COMPLETED (869/869 staged descriptions applied, 0 remaining, verified in quizroot_apply_state.json) so the daily run had become a pure no-op with three unmaintained hard-exit gates. Plist moved to ~/Library/LaunchAgents/_retired-tma/ (reversible). The guard jobs (ytchannelabout) stay |
— |
com.tma.ytchannelabout |
09:05 daily | youtube-analytics/channel_about_guard.py --apply — keeps the YouTube channel's About text on the UTM'd quiz root. Added 6 Aug 2026: the 869-video retrofit moved every description off /calisthenics-assessment/ but never touched the channel's own About prose, which still carried the old untagged doorway — it hid because the 4 structured channel links were all correctly tagged, so the links audit passed and nobody audits prose. 🔴 Runs at 09:05, deliberately BEFORE ytretrofit at 09:15 — the retrofit spends 9,800 of the 10,000 daily units, so anything after it is quota-dead (proven 6 Aug: even a 1-unit videos.list 403'd). Checks by parsing the public About page = 0 units when correct, and spends the 50-unit channels.update only when the text is actually wrong. Ships as a guard, not a one-shot patch, per the retrofit's own lesson that doctrine without a compliance check decays into decoration |
— |
com.tma.ytselflearn |
08:30 Tue | youtube-analytics/yt_selflearn.py |
— (pulls GA4+YT, scores prediction ledger, writes weekly CMO/CEO report to marketing/youtube/performance/weekly-reports/, refreshes yt_status.json) |
com.tma.growthsync |
every 15 min | finance/growth_repo_sync.py --pull-deploy |
— |
com.tma.n973to758 |
09:40 daily · TEMPORARY, self-completing | retention/migrate_973_to_758.py --batch 2000 (homebrew python; logs /tmp/tma_n973to758.*) — Aga-approved 29 Jul reroute of the ~8,799 contacts parked in 973 Nurture Sequence 1 NEW COPY FROM CLAUDE into 758 Nurture Sequence 2, 2,000/day to avoid a send spike. Add-verify-then-remove per contact; guard skips the add if already in 758; state+log in tools/retention/migrate_973_to_758_{state.json,log.csv}. Prints DONE and no-ops when the source is empty → then unload + delete this row (expected ~2 Aug 2026) |
— (triggers 758 Nurture Sequence 2 sends to migrated contacts) |
| com.tma.failedpurchase | 05:55 daily | retention/failed_purchase_lane.py --draft — the third retention lane (§2.5): ONE personal note per person whose purchase attempt died at checkout. Armed 31 Jul 2026 after CRO copy review + the full-family audit (JOB_AUDIT_FINDINGS_2026-07-31.md). Cannot auto-send | ✅ drafts |
| com.tma.transcriptcompress | 03:30 Sun | ~/Library/Scripts/tma/compress_claude_transcripts.sh (canonical: tools/ops/compress_claude_transcripts.sh — re-copy to ~/Library/Scripts/tma/ after editing; runs from home because §6 applies to bash reading the script off T9 too, not just python). Gzips Claude Code session artefacts >7 days old across ALL ~/.claude/projects — the disk hog is per-session subagents/ fan-out logs (60–100 MB each), which REGROW structurally. Skips files any process holds open (lsof-checked); compresses, never deletes; log ~/.claude/transcript-compress.log. Armed 5 Aug 2026 after swap exhaustion froze the Mac (5.3 GB → 3.7 GB same day). Launchd execution proven live via launchctl start before this row was written. 🔴 CORRECTION 10 Aug 2026: that proof predated the 6 Aug wrap — harden_launchd.py then set /bin/bash as the interpreter of the Python wrapper, and the job never ran once after wrapping (exit 2 every attempt, masked by seed-credit). Interpreter fixed to python3 10 Aug; first real wrapped run compressed 181 files / 110 MB freed, verified in ~/.claude/transcript-compress.log | — |
| com.tma.jobguardian | every 30 min + at boot | ~/Library/Application Support/tma/bin/job_guardian.py (boot volume; source: tools/ops/job_guardian.py) — the §6c safety net: detects + kickstarts missed windows, alerts on failures. Deliberately unwrapped so it runs with T9 unplugged. Hardened 10 Aug 2026: 🔴 red alerts (CRITICAL-job miss · deterministic failure · never-ran · T9 unplugged · guardian crash) Cc Aga's Gmail, max one per problem per day; 🟠 FYIs stay hello@-only (12h dedupe). Dedupe stamps only on confirmed delivery. Emails via a vendored stdlib Gmail sender on the off-T9 token (~/.config/tma/gmail_hello_token.json) so the "T9 unplugged" alert can actually send. Stops kicking a job that fails identically twice (deterministic → 🔴 with log tail instead). Detects never-ran jobs (seed credit expires after 3d — the transcriptcompress shape). Re-bootstraps not-loaded plists once/day. Writes guardian_history.jsonl (rolling 7d — feeds the 05:55 email's 🤖 Jobs line + the console band). Watched back by cockpit_watchdog check 15 (guardian_status ts <45 min) | ✉️ alert emails only |
| com.tma.everpaid | 02:10 daily (NEW 10 Aug 2026) | retention/ever_paid_build.py — rebuilds the payer-exclusion set incrementally (~50s; watermark = prior built −2d overlap; auto-FULL every 14d to unwind late refunds; atomic write that refuses an empty set). Kills the RC3 outage class: funnel_stage_export.py (02:30) and segment_guard.py both gate on this file being <48h old, and nothing used to refresh it — both also now self-heal via retention/ever_paid_fresh.py as backstop | — |
| com.tma.abandondraft | 06:05 daily (NEW 10 Aug 2026) | retention/quiz_abandon_manual_notes.py --draft — the personalised quiz-abandon notes previously drafted only when someone typed the command by hand. Drafts only; --live deliberately does not exist — Aga sends | ✅ drafts |
| com.tma.inboxtriage | 06:10 daily (NEW 11 Aug 2026) | retention/inbox_triage.py --draft — the lane for inbound no other lane owns. Classifies hello@ (support · out-of-office · sensitive · vendor · unknown-sender), builds the full customer brief (support_brief.py), drafts a personalised reply, labels the thread SUPPORT — LEIH SENDS, and emails Leih's morning page to hello@. 🔴 NO --send MODE EXISTS (Aga, 11 Aug: "for support emails that you didn't craft, i need you to NOT send them ever — they need to be sent by Leih, only, or Aga"), and send_payment_drafts.py refuses that label explicitly, proven by its own selfcheck. Sensitive mail (refund · injury · legal · anger · press) gets NO draft and is flagged NEEDS A HUMAN DECISION. Out-of-office is detected on the RFC 3834 Auto-Submitted header (never the subject) and becomes a dated row in follow_up_queue.py, +1 day — never a reply, never engagement, never a STOP. | ✅ drafts + emails the page to itself |
| com.tma.offerreach | 07:10 Mon (NEW 10 Aug 2026) | email-analytics/offer_reach_check.py — refreshes data/email-analytics/campaign_automation_map.json, which sequence_scoreboard.py red-banners at >14d. Producer was unscheduled; the map would have crossed the line ~20 Aug | — |
| com.tma.backup | 01:30 daily (NEW 10 Aug 2026) | ops/backup_snapshot.py — hard-linked incremental snapshots of the whole asset estate to ~/TMARepoBackups/, 14 kept. Replaces scripts/backup_repo.sh, which made a FULL ~54 GB copy per run, was never scheduled, excluded APP ASSETS/, and had grown to 233 GB — taking the boot disk to 97% full, the exact condition that froze the Mac in August. --link-dest means every snapshot after the first costs only the delta while staying a complete browsable tree (measured: a 65 GB tree for 12 GB of real disk). Includes .git (9.8 GB) — the origin remote is dead, so the history exists in exactly one place. Refuses to keep a snapshot missing any of marketing/ docs/ data/ tools/ CLAUDE.md, and prunes itself if free space drops under 40 GB. Watched by cockpit_watchdog check 17 (stale >36h or incomplete → 🔴). 🔴 These live on the SAME MAC — they survive the T9 drive dying and accidental deletion, NOT theft, fire or the Mac dying. An offsite copy is still an open decision for Aga. | — |
| com.tma.jobselftest | 04:10 Sun (NEW 10 Aug 2026) | ops/job_selftest.py — tests the smoke detector. Every guard in this estate was proven by inducing a real failure on 10 Aug; nothing re-proved them afterwards, and protection systems rot silently (the watchdog's own alert emails pointed at /tmp paths dead for 12 days). This drill re-induces all six weekly: hung job → killed at timeout (124) · flaky job → absorbed by retry · exit-0-without-writing → rejected by --proof (65) · bash-wrapped plist → caught by the lint · backdated ever-paid set → self-heals · deadman endpoint → rejects a bad secret, accepts a real beat. Writes tools/ops/job_selftest_status.json; a FAILED drill surfaces in Aga's 05:55 email, as does a drill that has not run in >9 days | — |
| com.tma.asopull | 05:15 daily (NEW 10 Aug 2026; RETIMED from 06:20 → 05:15 on 11 Aug 2026 — at 06:20 it landed AFTER both daily surfaces (Slack 05:29, email 05:55), so every morning both rendered the PREVIOUS day's App Store pull. Now first in the morning chain. Backup: ~/Library/LaunchAgents/com.tma.asopull.plist.bak-2026-08-11) | aso/asc_analytics_pull.py --download then aso/aso_daily.py — refreshes data_aso_daily.json (App Store installs/deletes funnel). Was unscheduled: the cockpit card + Slack post quoted a 31 Jul pull as current for 10 days. Both surfaces now also render "as of
Since 29 Jul 2026 (§6c) every job above runs THROUGH
tma_job_run.py— same interpreter, plus T9 mount-wait, persistent logs (~/Library/Logs/tma/<label>.log— NOT/tmpanymore), and success stamps. New job? Write the plist, runharden_launchd.py --apply, add its row here.Wrapper hardened 10 Aug 2026: every job now gets a timeout (default 45 min per attempt,
--timeoutoverrides — funnelstage/everpaid/n973to758 carry 7200s; a hung job used to hang FOREVER, invisible) and one transient retry after 90s (--retry, default 1 — zeroes the 503/timeout class that produced the only 4 genuine failures in ~2,000 runs). Stamps recordretries+timed_out. 🔴 The wrapper's plist interpreter is ALWAYS python3 — reusing the job's own interpreter is the bug that brickedtranscriptcompressfor 4 days (bash "ran" the .py wrapper →import: command not found, exit 2, never once executed; fixed inharden_launchd.pythe same day).Exit-code semantics settled 10 Aug 2026: for CHECKER jobs (
cockpitwatchdog), exit 0 = "the checks RAN" — findings are the checker's own emails, deduped one per finding per day. Exit ≠0 is reserved for "the checker itself could not run". The old semantics (findings → exit 1) made the guardian kick a healthy watchdog into an alert storm — up to 6 duplicate emails/day about one item, plus a false "watchdog MISSED" alarm.
com.ghm.* and com.aga.* jobs belong to Gut Healing Mama / other projects — not TMA, not listed here.
tools/retention/)| File | Holds |
|---|---|
r1_status.json |
Machine state read by the watchdog, the cockpit and any LLM |
sent_log.csv |
Every R1 note sent, by segment |
reason_log.csv |
The churn-reason ledger (214 rows, 24 Jul) |
replies_state.json · reply_fulfil_state.json |
Idempotency for the two draft lanes |
suppress.csv |
STOP replies + bounces — never emailed again |
payment_recovery_state.json · payment_recovery_log.csv |
Dunning lane state; mode = draft / draft-sent |
upgrade_log.csv · upgrade_stage_log.csv · concierge_monthly.csv |
Monthly→Annual engine (990 Monthly→Annual Upgrade Engine) |
known_reasons.csv · survey_state.json |
Exit-survey cross-reference (5,022 rows, 24 Jul) |
| Job logs | ~/Library/Logs/tma/<label>.log (since §6c, persistent, rotated; /tmp paths are dead) |
| Success stamps | ~/Library/Application Support/tma/stamps/<label>.json — last_success = the "did it run?" truth |
| Guardian status | ~/Library/Application Support/tma/guardian_status.json (mirror: tools/ops/guardian_status.json) |
Any launchd job that reads /Volumes/T9 MUST use /opt/homebrew/bin/python3 or
tools/finance/venv/bin/python. NEVER /usr/bin/python3.
Apple's CommandLineTools python has no Full Disk Access, so it cannot even open a file on the external volume — the job dies before your script runs:
/Library/Developer/CommandLineTools/usr/bin/python3: can't open file
'.../payment_recovery_lane.py': [Errno 1] Operation not permitted
This silently killed com.tma.paymentrecovery from the day it was loaded (22 Jul) until 24 Jul —
it never produced a single draft. Every draft in that lane had been made by hand. A launchd job
that fails this way leaves an empty stdout log, so it looks idle rather than broken.
6a. The sibling trap — a job that needs a CLI, not just python. com.tma.seodailysync
ran green for days while silently failing its last step: the plist set no PATH, so
subprocess.run(["netlify", ...]) died with FileNotFoundError: 'netlify' after every
data pull had succeeded. The sync worked; only the deploy was lost, so the cockpit quietly
served stale SEO numbers. Fixed 29 Jul by adding the standard block every other TMA job
already carries:
<key>EnvironmentVariables</key>
<dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string></dict>
Law for new jobs: if the script shells out to ANY CLI (netlify, gh, ffmpeg), the plist must
set PATH. launchd's default PATH is /usr/bin:/bin:/usr/sbin:/sbin — Homebrew is not on
it. Verify with launchctl print gui/501/<job> | grep PATH.
Health check (run any time):
launchctl list | grep com.tma # col 2 = last exit status; non-zero = failing
tail -5 /tmp/payment_recovery.err /tmp/tma_r1_daily.err
launchd does NOT backfill a StartCalendarInterval job whose window passed while the Mac
was powered off. (It does catch up after sleep — power-off is the gap.) The morning of
29 Jul the machine booted at 06:39, so the 05:35 (com.tma.r1daily) and 05:50
(com.tma.paymentrecovery) windows were simply lost: no reply scan, no dunning drafts, no
status refresh, and the live T-30 pre-expiry notes never went.
How to spot it — the signature is different from §6:
| §6 python-path trap | §6b power-off skip | |
|---|---|---|
launchctl print |
runs climbing, non-zero exit |
runs = 0 · last exit code = (never exited) |
/tmp logs |
present but empty | absent entirely (/tmp clears on boot) |
| State files | stale | stale, and uptime is younger than the missed window |
Confirm in one line: last reboot | head -3 — if the boot time is after the job's window,
the run was skipped, not broken.
Catch-up runbook (what was run on 29 Jul):
/opt/homebrew/bin/python3 tools/retention/check_replies.py # drafts only, safe
/opt/homebrew/bin/python3 tools/retention/reply_fulfil.py # drafts only, safe
/opt/homebrew/bin/python3 tools/retention/payment_recovery_lane.py # DRY first, always
/opt/homebrew/bin/python3 tools/retention/r1_daily.py # ⚠️ step 1 SENDS LIVE — Aga approves first
All four are idempotent (state files), so a catch-up run cannot double-send. r1_daily.py
needs Aga's word every time because its T-30 watcher sends live member email.
✅ CLOSED 29 Jul 2026 (PM) — see §6c. The guardian + wrapper now make this class of miss self-healing and loudly-alerted. The catch-up runbook above remains valid for manual use.
Every com.tma.* job now runs THROUGH a wrapper, and a new 18th job watches the other 17.
Built + verified the evening of the §6b incident. Source of truth: tools/ops/ in the repo.
1. tma_job_run.py — the universal wrapper (installed at
~/Library/Application Support/tma/bin/, runs under each job's ORIGINAL python so TCC/FDA
identity is unchanged — §6 law respected, bash never touches T9):
- Waits up to 15 min for /Volumes/T9 to mount instead of dying at spawn (the T9
WorkingDirectory was removed from every plist and moved into the wrapper — launchd can no
longer kill a job at boot because the volume wasn't mounted yet).
- Persistent logs: ~/Library/Logs/tma/<label>.log (timestamped per run, 5 MB rotation).
/tmp logs are GONE — §6b's "logs absent after reboot" can't recur.
- Success stamps: ~/Library/Application Support/tma/stamps/<label>.json — last_success
moves only on exit 0. This is the machine-readable answer to "did it actually run today?"
2. com.tma.jobguardian — the safety net (every 30 min + RunAtLoad at boot;
deliberately UNWRAPPED and on the boot volume so it runs and alerts even with T9 unplugged):
- Parses the live plists each pass (plists ARE the manifest — new jobs auto-covered, nothing
to drift), computes each job's most recent scheduled window, compares to its stamp.
- Missed window + 25 min grace → launchctl kickstart the job (max 2 kicks per
occurrence, then alert-only). A power-off morning now self-heals within ~30 min of boot.
- Also checks each pass: job loaded at all · non-zero last exit · T9 mounted · Gmail token
freshness (<14 d) · installed wrapper/guardian hash vs repo copy (drift self-reported).
- Alerts: macOS notification + email to hello@ (existing r1_lib rail, deduped 12 h/issue)
+ status JSON ~/Library/Application Support/tma/guardian_status.json (mirrored to
tools/ops/guardian_status.json when T9 is mounted).
Verified on install night: wrapped paymentautosend ran clean under launchd (homebrew
python through wrapper, TCC intact) · stamp rewound to simulate a §6b miss → guardian
KICKED the job, it ran, re-stamped, next pass clean · guardian caught a real same-night
issue (cockpitrefresh netlify 403, transient — redeployed OK) and its alert email landed in
hello@ at 21:05.
Operational notes:
- harden_launchd.py --apply is idempotent — re-run it after editing wrapper/guardian in the
repo, or to enrol a NEW job (new jobs get wrapped + stamped + guarded automatically; write
the plist, run --apply). --audit = read-only table.
- Plist XML comments were stripped by the rewrite — the annotated originals live in
tools/ops/launchd_snapshots/2026-07-29-2059/ (+ ~/Library/LaunchAgents/tma-backup-*).
- launchctl kickstart can BLOCK while a service is inside its ThrottleInterval — the
guardian's kick calls carry a 120 s timeout for this reason.
- ✅ Root cause closed same session: pmset repeat wakeorpoweron MTWRFSU 05:20:00 applied
with Aga's password (via osascript admin-privileges dialog — plain sudo has no TTY under
the ! runner) and verified: pmset -g sched → "wakepoweron at 5:20AM every day". The Mac
is awake before the earliest window (05:35), so launchd's no-backfill-after-power-off gap
cannot bite. The guardian remains the net beneath it. To inspect/remove:
pmset -g sched · sudo pmset repeat cancel.
# What's sitting in hello@ right now (dry, sends nothing)
python3 tools/retention/send_payment_drafts.py
# Send the approved dunning drafts (this lane only — never touches reply drafts)
python3 tools/retention/send_payment_drafts.py --send
# R1 reply drafts: open hello@ → Drafts → read → send by hand.
# They are personal 1:1 replies. Read them; the reply IS the save.
Send-time doctrine: mornings. Read tools/retention/R1_SYSTEM_STATE.md (current state +
work queue) and tools/retention/AGA_RUNBOOK.md (how to send) before touching a lane.
upgrade-eligible + the UPGRADE_LIVE sentinel file.com.tma.seoselflearn added 24 Jul PM); com.tma.paymentrecovery fixed and re-bootstrapped today.Every other guard in this document runs ON Aga's Mac. If the Mac powers off, panics, or loses its login session, every job and every alarm about those jobs goes silent together — and silence is indistinguishable from health. That was the last uncovered failure class in the estate.
https://tma-deadman-switch.netlify.app — a tiny site on Netlify's infrastructure,
deliberately sharing nothing with the Mac. Source: tools/ops/deadman/.
beat — the job guardian POSTs here every pass (~30 min), authenticated by a
shared secret (tools/ops/deadman/.secret.local, gitignored; Netlify env
DEADMAN_SECRET). The guardian prints deadman ✓ / ✗ on every pass.check — runs on Netlify cron, hourly (17 * * * *) and watches TWO
independent signals: the heartbeat (stale >3h ⇒ the Mac is gone) and the live
cockpit page's own auto-refreshed stamp (stale >30h ⇒ the morning pipeline died
even if the Mac is alive). Alerts Slack, re-notifying at most every 6h, and posts
a single "recovered" when beats resume.🔴 Slack, not email, on purpose: every email rail in the estate runs through the Mac's Gmail token, so email cannot be trusted to report the Mac's own death.
🔴 Two Netlify traps, both paid for on 10 Aug 2026: (1) an env var is invisible to a
function unless set with an explicit --scope functions runtime — the only symptom
is a 403 that looks like a wrong secret; (2) netlify env:set --site X is silently
IGNORED when the working directory is linked to a different site — it writes to the
linked one. Link the directory first, then set. Also: a scheduled function correctly
returns 403 to HTTP — that is proof the schedule is attached, not a fault.
| Date | Change |
|---|---|
| 2026-08-10 (part 2) | THE SECOND HARDENING PASS — closing what the first one left. (1) 🛰️ Off-machine dead-man's switch (§8b) — the last zero-coverage class; proven by forcing both signals stale and watching the Slack alert land, then confirming the 6h rate-limit suppressed the duplicate. (2) Plist lint in the guardian — catches a mis-wired job on the NEXT PASS instead of after 3 days; proven by re-creating the exact /bin/bash-runs-the-python-wrapper bug and watching it fire red. (3) --proof at the wrapper — enforces Aga's own "exit 0 lies when the write is at the end" law in code: a job that exits 0 without refreshing its named artifact is rejected with exit 65; wired into everpaid, emailmetrics, asopull, offerreach. (4) Weekly drill com.tma.jobselftest (Sun 04:10) — re-induces all six failures automatically, because a guard nobody exercises is a guard nobody can trust; a FAILED drill surfaces in the 05:55 email. (5) Slack as the guardian's second alert rail — all alerting rode ONE Gmail token; now a failed send falls through to Slack, with the webhook cached off-T9 (~/.config/tma/slack_alert_webhook, 0600) because the first version read it from .env.local on the drive whose absence is the alert — caught by induced test. (6) Two closers: the 05:45 PM brief now carries the same 24h jobs line (it had zero job health), and the guardian flags any job running without a register row — which found com.tma.socialselflearn unregistered on its first pass. |
| 2026-08-10 | THE HARDENING PASS — "these jobs NEVER break" (Aga's directive). Root-caused all 14 failures in ~2,000 recorded runs to 6 causes and closed each class. (1) transcriptcompress had NEVER run — harden_launchd.py reused the job's own interpreter for the wrapper, so bash "ran" a Python file (exit 2 every run, hidden by seed-credit); plist fixed, harden bug fixed, first real run compressed 181 files / 110 MB. (2) Watchdog exit semantics split: findings now exit 0 + dedupe 1/finding/day — was up to 6 duplicate emails/day + false MISSED alarms (the alarm alarming on the alarm). (3) RC3 class (freshness-gated artifact, unscheduled producer) killed: ever_paid_build.py made incremental (50s vs 60min) + atomic + auto-full/14d, scheduled (everpaid 02:10); segment_guard + funnel_stage_export share one self-heal (ever_paid_fresh.py); offerreach (Mon 07:10) + asopull (06:20) scheduled; guardian's token check repointed to the LIVE off-T9 token (false alarm was due 14 Aug). (4) Wrapper: per-attempt timeout (45min default, 7200s on the 3 long jobs) + one 90s transient retry. (5) Guardian: red-only Cc Aga (her ruling — digest + red-only), dedupe-after-delivery, vendored off-T9 Gmail sender, deterministic-failure stop (2 identical failures → 🔴 with log tail, no more kicks), never-ran detection, not-loaded re-bootstrap, guardian_history.jsonl, crash self-report; watchdog check 15 watches the guardian back. (6) Digest: 05:55 email carries a 🤖 Jobs line from 24h history; console band humanized; dead /tmp paths in alerts fixed; 4 unguarded cockpit-refresh steps soft-failed. ytretrofit retired (869/869 done). Everything proven by induced failure, not assertion — deterministic ladder (kick→kick→🟠→🔴+stop), watchdog finding (exit 0, one email, dedupe verified), hung job (killed at timeout, exit 124), flaky job (absorbed, exit 0, retries:1 stamped), heal (backdated 72h → rebuilt 46s → re-read artifact). Final guardian pass: 29 ok · 0 kicked · 0 issues. |
| 2026-07-31 | Failed-purchase lane ARMED (com.tma.failedpurchase, 05:55 daily, drafts-only) after Aga's pre-flight: CRO copy review (2 line edits — "cross-border charge", "call your bank"; dual-exit structure kept as load-bearing) → full-family bug audit, 30 findings → documentation. 8 fixed same session, incl. 🔴 C1: nothing was watching replies to dunning/failed-purchase emails — a STOP never reached suppress.csv (fix immediately surfaced a real unread reply from 28 Jul) · C2: upgrade_tag KeyError that would have broken 990 Monthly→Annual Upgrade Engine the day it armed · H2: unpaginated ever_paid could have emailed a paying member · M7: non-atomic shared Gmail token write (would take every hello@ lane dark) · H1: my own malformed plist that also crashed harden_launchd.py. Full register + 12 open items: JOB_AUDIT_FINDINGS_2026-07-31.md. Also corrected §0 (14-hour rule is a third auto-send exception) and §2.5's false reply-pipeline claim. |
| 2026-07-29 | §6c — HARDENING SHIPPED (PM session). All 17 jobs rewrapped via tools/ops/harden_launchd.py --apply: universal wrapper (T9 mount-wait · persistent ~/Library/Logs/tma/ logs · success stamps), new 18th job com.tma.jobguardian (30-min + boot safety net: parses live plists, kickstarts missed windows, alerts via notification + hello@ email + status JSON). Missing PATH fixed on cockpitemail + seoselflearn. Verified: simulated §6b miss auto-kicked + self-healed; real cockpitrefresh netlify 403 caught + alerted same night (transient — manual redeploy OK). Plist snapshots: tools/ops/launchd_snapshots/2026-07-29-2059/. Root cause ALSO closed same session: pmset repeat wakeorpoweron MTWRFSU 05:20:00 applied + verified (§6c). |
| 2026-07-29 | Registered com.tma.n973to758 (09:40 daily, homebrew python, /tmp logs — §6-compliant; temporary, self-completing) — Aga-approved reroute of the ~8,799 contacts parked in 973 Nurture Sequence 1 NEW COPY FROM CLAUDE (whose emails contain zero offer links) into 758 Nurture Sequence 2, batched 2,000/day. Pilot 5 contacts verified end-to-end (758 record active, 973 record(s) gone, duplicate records cleaned); batch 1 (1,995) launched same session. Unload + delete the §4 row when it prints DONE (~2 Aug). Same session: ambassador/affiliate links stripped from all 32 live emails of 751 Nurture Sequence 1 + 758 Nurture Sequence 2 via PUT /api/3/messages/{id}; originals in marketing/email-sequences/_BACKUPS/751-758-pre-linkfix-2026-07-29/. |
| 2026-07-29 | §6b added — the power-off skip. Mac booted 06:39, so the 05:35 + 05:50 windows were lost (runs = 0, /tmp logs absent). Caught up by hand: check_replies 0 new · reply_fulfil 0 new · dunning dry 0 due today (in-lane 20, +1 recovered) · r1_daily full run on Aga's approval → T-30 watcher sent 3 live (mike.goldman@me.com · mail.christophe.martel@gmail.com · fatma.tbee@gmail.com), survey 5,029 rows, queue 111 / $1,568.57, cockpit patched. hello@ drafts before and after: 0. |
| 2026-07-29 | One draft added by hand — reply to Goran (goranf@gmail.com, lifetime, abs progression stuck) in his existing thread. Not a lane draft, so send_payment_drafts --auto can never touch it (it hard-filters to the 3 dunning subjects). Awaiting Aga/Leih. |
| 2026-07-24 | Registered com.tma.seoselflearn (Mon 08:45, venv python, /tmp logs — §6-compliant from day one) — the SEO fleet redesign's weekly self-learn: pure-Python watch adjudication (GSC per-page 14d post vs pre; REVERT-FLAG never auto-reverts) + weekly CMO/CEO report + human-queue re-rank. Loaded + verified (launchctl list), dry-run + real run both clean (12 armed watches found, 0 flags). |
| 2026-07-24 | Extended com.tma.seodailysync — now also renders marketing/seo/SEO_DAILY_BRIEF.md (5 fleet KPIs: organic quiz-starts d7/d28 generate_lead · GSC both domains · AI-referral sessions · link-integrity guard · changelog throughput 24h) + the ≤3/≤3 AGA/NIC human lists from the new tracked queue marketing/seo/SEO_HUMAN_QUEUE.json (§12.7-E contract), mirrored into the cockpit SEO-LIVE block. Also fixed a latent bug: gsc_pull summary --json returns a LIST, but the renderer only read dicts — the cockpit GSC cells had silently rendered "—" since the block was born. Zero LLM tokens throughout. |
| 2026-07-24 | Fixed com.tma.socialdash — every run since creation exited 126: launchd ran the .sh via /bin/bash, which has no Full Disk Access to /Volumes/T9 (§6 class). Ported to tools/social-pipeline/refresh_social_dashboard.py run by the finance venv python; plist rewritten + reloaded; verified live (board deployed to tma-social-scoreboard.netlify.app). The .sh stays for manual use. |
| 2026-07-24 | Fixed com.tma.seodailysync — never spawned (exit 78, no log files ever created): StandardOutPath/StandardErrorPath pointed at /Volumes/T9 paths launchd cannot create (§6 class — same law, log-path variant). Moved logs to /tmp/tma_seo_sync.out|.err, reloaded. NOTE for new jobs: logs go to /tmp, never onto T9. |
| 2026-07-24 | Fixed com.tma.paymentrecovery — /usr/bin/python3 → /opt/homebrew/bin/python3, added PATH + WorkingDirectory to match com.tma.r1daily. plutil OK, re-bootstrapped, dry-run verified (3 candidates found). Backup: com.tma.paymentrecovery.plist.bak-2026-07-24. |
| 2026-07-24 | Sent all 5 waiting drafts (4 dunning T2/T3 + 1 R1 reply). hello@ drafts → 0. |
| 2026-07-24 | ⏸️ Aga parked the failed-purchase lane — new checkouts incoming will feed richer Stripe data; build against that signal, not today's. R1 + dunning continue unchanged. PI-metadata requirement moved to a build-time ask on the new checkouts. |
| 2026-07-24 | Built failed_purchase_lane.py (§2.5) — the third lane. Dry-run clean: 30d window → 14 people, 9 actionable, 5 correctly skipped. Not scheduled, not armed. |
| 2026-07-24 | Established that Stripe holds every failed purchase attempt (charge + decline code + email + cardholder name + amount) but carries NO plan/coupon — metadata is empty on both charge and PaymentIntent. Plan is inferable from amount only. Nic fix = write plan_slug/coupon/user_id into PI metadata — the same hook already briefed for cart-abandon Path B (NIC-CART-ABANDON-STRIPE-AC-BRIDGE-2026-07-22.md). |
| 2026-07-24 | This register created. |
| 2026-07-23 | 18 of 22 dunning drafts approved + sent. |
| 2026-07-22 | com.tma.paymentrecovery first loaded (broken from day one — see §6). |