through-the-cracksVersion 1.0

Silent client churn detector

Catch clients who stopped reaching out before they quietly leave. This skill flags who initiates contact and drafts a weekly check-in so no account slips away.

“Here is what I noticed. Nothing was wrong with any single email. But the pattern was that she was the one starting almost every conversation. I was responsive, I was thorough, I delivered on time. She was the one initiating. She was the one nudging. She was the one keeping the relationship alive while she was paying me to manage the work.”
Jason (jlittrell.com), The Ops Wire (Substack)

The problem

A client can leave without ever saying so. They reply more slowly, then stop. You're too deep in the work to catch that on your own, so track who reached out last with each client, review it every week, and send a plain check-in the moment one goes quiet. That turns a guessing game into a repeatable system.

Silent client churn detector

Key takeaways

  • Silent churn means a client disengages gradually with no complaint. You won't notice until the account is already gone, unless you're tracking who reached out last.
  • Track who reaches out first. If you've stopped being the one who starts the conversation, that's the signal, no matter how warm the client still sounds.
  • A same-day reply rule combined with a weekly proactive-contact review catches drift before it becomes a lost account.
  • Reaching out first costs you one short message. Waiting for a silent client to speak up can cost you the whole account.
  • A client who replies or reaches out on their own resets the clock. The system should not re-flag someone who was just busy.
  • Two unanswered check-ins in a row is a different problem than silent churn. At that point the skill stops drafting and you decide what happens next by hand.
SKILL.md

Frontmatter

name
silent-churn-detector
description
|

Silent client churn detector

This skill maintains a contact-initiation ledger across the owner's active clients and runs a recurring review loop that flags accounts where the owner has stopped initiating contact, drafts a proactive touch for the owner to approve, and logs the result. Invoke it when the owner asks whether a client has gone quiet, wants a system to catch disengagement before it becomes a lost account, reports that they are always the one responding and never the one starting the conversation, or asks to set up or run a check-in cadence.

The bundle: references/contact-ledger-schema.md (the ledger fields and setup rules), references/risk-scoring.md (how to turn ledger dates into a risk tier), references/cadence-rules.md (response-time rule, touch intervals, reset and stop conditions), references/touch-templates.md (four proactive-touch templates by trigger type), examples/contact-ledger-example.md (a filled ledger), examples/filled-checkin-example.md (a worked drafted touch end to end), and scripts/compute_initiation_gap.py (reads the ledger and outputs a risk tier per client).

Phase 1: Build the ledger (one time, per client roster)

  1. Check whether the owner already has a contact ledger (a spreadsheet, CRM export, or prior run of this skill). If none exists, create one using the field list in references/contact-ledger-schema.md: client_name, contract_value_or_tier, last_owner_initiated_contact_date, last_client_initiated_contact_date, next_scheduled_touch_date, risk_tier, notes.
  2. Pull the client roster from whatever source the owner has (CRM, email, invoicing tool, or a plain list they give you). List every client who currently has active, paid work or an open relationship. Do not include past clients with no current engagement.
  3. For each client, fill last_owner_initiated_contact_date and last_client_initiated_contact_date only from real evidence: an email thread, a CRM activity log, a calendar entry, or the owner's direct statement. If a date is not known, write unknown in that field and add the client to a short "needs owner input" list instead of guessing or defaulting to today's date.
  4. Run scripts/compute_initiation_gap.py against the ledger to compute each client's initiation gap (days since the later of last_owner_initiated_contact_date and last_client_initiated_contact_date, per references/risk-scoring.md's effective-last-contact rule, compared against the client's own cadence baseline) and assign an initial risk tier (green, amber, red) using the thresholds in references/risk-scoring.md. Any client still marked unknown gets risk tier needs input; the script does not guess a tier for it.
  5. Set the response-time rule from references/cadence-rules.md: any message a client sends gets a reply from the owner the same business day. Confirm with the owner that this rule is realistic for their schedule and adjust the threshold in references/cadence-rules.md's local copy if they want a different same-day window (for example, same-day meaning by end of day versus within four hours). Any client message that misses this same-day window gets logged in that client's notes field as late-reply: YYYY-MM-DD (references/cadence-rules.md §1), so the rule has a record behind it: checked every week in step 7 below and walked for patterns every quarter in step 17.
  6. Set the review cadence: weekly, on a day and time the owner picks. Record that cadence in the ledger's notes. The hosting agent or scheduler owns the recurring trigger: where recurring invocation is available, set it to re-run Phase 2 at this cadence; where it is not, tell the owner to set a calendar reminder and re-invoke this skill at the same weekly cadence.

Phase 2: Run the weekly review loop (repeated, every cycle)

  1. At the start of each review cycle, re-run scripts/compute_initiation_gap.py over the full ledger to refresh every client's initiation gap and risk tier. Do not rely on last cycle's tiers; dates change every week even for clients nobody touched. As part of this same refresh, check every client message received since the last review against the same-day reply rule (references/cadence-rules.md §1); log any message that missed a same-day reply in that client's notes field as late-reply: YYYY-MM-DD.
  2. Sort the refreshed ledger by risk tier (red first, then amber, then green, then needs input) and within each tier by days since last_owner_initiated_contact_date, longest gap first.
  3. For every client at amber or red, select the matching template from references/touch-templates.md based on what is actually true for that account right now: a routine check-in (no news, just reaching out), a result to share (a deliverable, a metric, something concrete finished), a response to a life or business event the owner has independently confirmed (never invented, see guardrails), or a renewal-adjacent check-in (contract or engagement nearing a natural renewal point). Fill the template with only details present in the ledger, the owner's own notes, or what the owner supplies in this turn.
  4. Present every drafted touch to the owner, one at a time or as a batch, clearly labeled with the client name and the reason it was flagged (risk tier and gap in days). Do not send, schedule, or queue any touch. Wait for an explicit yes from the owner in this turn before treating any draft as approved.
  5. On approval, hand the approved text to the owner's own send channel (their email client, CRM, or messaging tool). This skill never sends a message itself. Once the owner confirms the touch was actually sent, update that client's last_owner_initiated_contact_date to the date it was sent, per references/cadence-rules.md's reset rule; a touch that was only approved but not yet confirmed sent does not update any date. Also set next_scheduled_touch_date to the date of this client's next weekly review, so the ledger shows when they are next due for a look; clear the field once that date passes and a new touch is logged for the following cycle. The risk tier recomputes automatically the next time scripts/compute_initiation_gap.py runs against the ledger; do not set it by hand.
  6. On a rejection, an edit request, or "skip this one," revise the draft per the owner's direction and re-present it, or mark the client as skipped for this cycle without penalizing or re-flagging them until the next cycle. Never substitute your own judgment for the owner's decision to skip a client.
  7. For any client who replies or initiates contact on their own between cycles, log that as last_client_initiated_contact_date set to today. This resets the gap clock per references/cadence-rules.md's reset rule: a client-initiated contact counts as a fresh touchpoint and removes the client from this cycle's flagged list even if the owner has not yet replied; the same-day response rule from step 5 is checked and logged on its own, in step 7.
  8. If a client has two consecutive proactive touches sent with no reply (tracked in the ledger's notes field), stop drafting further proactive touches for that account automatically. Flag it for the owner's manual review instead of continuing the cadence, since repeated unanswered outreach from the system is a different problem than silent churn and needs the owner's own judgment on what to do next.

Phase 3: Quarterly cadence audit (repeated, lower frequency)

  1. Once a quarter, walk the ledger with the owner and ask which flagged, approved touches actually led to a real conversation or a saved account, and which flags turned out to be noise (a client who was simply busy, not disengaging). Use this to recalibrate the gap thresholds in references/risk-scoring.md for the owner's actual client mix; a thirty-day gap might be normal for a quarterly retainer client and alarming for a weekly-touch client.
  2. Retire any client from the ledger whose engagement has intentionally ended (a completed project with no renewal, a mutual parting), only on the owner's explicit confirmation. Do not remove a client from the ledger just because they have been flagged red for a long time. A long red flag is exactly the signal this system exists to surface, and removing the client would mean losing that signal right when it matters most.
  3. Walk the late-reply: entries logged in step 7 since the last quarterly audit to see whether missed same-day replies are a one-off slip or a recurring pattern for a particular client or for the owner's workload generally. Then confirm the response-time rule and review cadence from Phase 1 are still the ones the owner wants running, and adjust them if their workload or client count has changed.

Copy-paste checklist: every weekly review

  • Ledger re-scored with scripts/compute_initiation_gap.py at the start of this cycle, before sorting
  • Past week's client messages checked against the same-day reply rule; any miss logged as late-reply: YYYY-MM-DD in notes (references/cadence-rules.md §1)
  • Every amber or red client has a drafted touch, or an explicit owner decision to skip this cycle
  • Every draft uses only details present in the ledger, notes, or what the owner supplied this turn
  • No draft sent, scheduled, or queued without the owner's explicit approval this turn
  • Approved touches logged back to the ledger with the date they were actually sent (risk tier recomputes on the next script run)
  • next_scheduled_touch_date set to the next review cycle for every client with a touch confirmed sent this cycle
  • Clients with two unanswered touches pulled out of the automatic draft queue and flagged for owner review

Copy-paste checklist: drafting one proactive touch

  • Correct template selected from references/touch-templates.md for what is actually true about this client right now
  • Client name, contract detail, and any referenced result pulled from the ledger or confirmed by the owner, nothing assumed
  • Tone matches the "check-in, not a pitch" rule: no upsell, no ask, unless the owner specifically wants one folded in
  • Draft shown to the owner with the reason it was flagged, before any approval is requested

Guardrails: the ways this fails

  • Never send, schedule, or queue a message to a client. Every touch is drafted, shown to the owner with its reason, and approved in that turn before it leaves the draft state.
  • Never fabricate a client detail: a name, a contract value, a date, a result, or a "life event." If the ledger, CRM, or email thread does not contain it and the owner has not stated it in this turn, mark the field unknown and ask, rather than inventing something plausible.
  • Never assign a risk tier to a client whose contact dates are unknown. Those clients get needs input, and stay there until the owner supplies real dates.
  • Never let a proactive touch read as a sales pitch. The move is a check-in by default; folding in an upsell or ask is only the owner's explicit choice in that turn.
  • Never continue drafting touches for a client who has already replied in the current cycle. Log the reply, reset the gap, and drop them from the flagged list immediately.
  • Never keep drafting unanswered touches past two in a row for the same client. Stop and hand that account back to the owner for a manual call.
  • Never remove a client from the ledger, or mark an engagement "ended," without the owner's explicit confirmation in that turn.

Install

  1. Download the .skill bundle, or the raw SKILL.md above.
  2. Unzip the bundle into your agent's skills directory. SKILL.md sits at the top level; any reference files sit beside it.
  3. Point your agent at the folder. It reads SKILL.md and follows it.

What's in the bundle

The .skill download contains SKILL.md plus these files, each at its path.

SKILL.mdthe skill on this page
references/contact-ledger-schema.md3,592 chars
# Contact ledger schema

This is the file layout the whole system runs on. Build it once, keep it updated every cycle. A spreadsheet or a CSV both work, as long as the seven fields below exist with these exact names.

## Fields

| Field | Type | Rule |
|---|---|---|
| `client_name` | text | One row per client. If a client has two contracts, keep one row and note the second in `notes`, do not split them. |
| `contract_value_or_tier` | text | Free text, but include the cadence word the client actually runs on: "weekly," "biweekly," "monthly," or "quarterly." Example: `Monthly retainer, $1,200/mo`. This word drives the risk thresholds in `references/risk-scoring.md`. If no cadence word is present, the scoring script assumes monthly. |
| `last_owner_initiated_contact_date` | date, `YYYY-MM-DD`, or the literal `unknown` | The last date the owner sent the first message in a thread: an email, a call, a text, a scheduled check-in. A reply to something the client sent first does not count here, only a message the owner started. |
| `last_client_initiated_contact_date` | date, `YYYY-MM-DD`, or the literal `unknown` | The last date the client reached out first: a question, a reply after a gap, a new request. |
| `next_scheduled_touch_date` | date, `YYYY-MM-DD`, or blank | Set by step 11 of the main procedure to the date of this client's next weekly review, every time a touch is confirmed sent, so the ledger always shows when they are next due for a look. Also used for a specific future-dated touch that is already drafted and approved ahead of the normal cycle, a renewal-adjacent check-in on a known date, for example. Cleared once that date passes and a fresh touch is logged for the new cycle. |
| `risk_tier` | one of `green`, `amber`, `red`, `needs input` | Set automatically by `scripts/compute_initiation_gap.py` each run. If you set it by hand before the script is part of your workflow, treat that value as a placeholder the next run will overwrite. |
| `notes` | text | Free text. Use it for: why a client is on a different cadence than their tier suggests, a record of unanswered touches (see `references/cadence-rules.md` for the exact format), a record of any missed same-day reply (`late-reply: YYYY-MM-DD`, same file), and the reason behind a skip. |

## Setup rules

1. **Only active clients.** A client with no current paid work or open relationship does not belong in the ledger. A finished project with no renewal in sight gets removed from the ledger rather than flagged for review.
2. **Dates come from evidence, not memory.** Pull them from an email thread, a CRM activity log, a calendar entry, an invoice date, or a direct statement from the owner in this conversation. If none of those exist for a client, write `unknown` in that field. Do not default to today's date and do not guess a plausible-sounding date.
3. **`unknown` clients go on a separate short list.** Call it "needs owner input" and work through it with the owner before the first scoring run, one client at a time: when did you last reach out, when did they last reach out. Once both dates are filled, the client moves into the normal ledger.
4. **One date format throughout.** `YYYY-MM-DD` everywhere, no exceptions, so the script can parse every row the same way.
5. **The ledger is a living file.** Every approved touch updates `last_owner_initiated_contact_date` and `next_scheduled_touch_date`. Every client-initiated reply updates `last_client_initiated_contact_date`. Both updates happen the same day the event happens; waiting for the next weekly review to log them is too late.
references/risk-scoring.md3,067 chars
# Risk scoring

How a ledger row turns into a risk tier. This is the logic `scripts/compute_initiation_gap.py` runs; this file is the reference copy so you can score a row by hand if you ever need to check the script's output.

## Step 1: find the cadence baseline

Read the cadence word out of `contract_value_or_tier` (see `references/contact-ledger-schema.md`):

| Word found | Baseline, in days |
|---|---|
| "weekly" | 7 |
| "biweekly" or "bi-weekly" | 14 |
| "monthly" | 30 |
| "quarterly" | 90 |
| none of the above | 30 (monthly is the default assumption) |

The baseline is specific to this client: how often they personally expect to hear from the owner. It is set per account, not as one company-wide rule. A weekly-touch client who goes two weeks without a word from the owner is in a different spot than a quarterly retainer client at the same two weeks.

## Step 2: find the effective last-contact date

Take the later of `last_owner_initiated_contact_date` and `last_client_initiated_contact_date`. A client who reached out on their own counts as a live, current relationship even if the owner has not yet replied (the separate same-day reply rule in `references/cadence-rules.md` covers that gap). If either date is `unknown`, use whichever date is present; if both are `unknown`, the client cannot be scored (see Step 4).

## Step 3: compute the gap and assign a tier

`gap = (today's date) - (effective last-contact date)`, in days.

| Gap, compared to baseline | Tier |
|---|---|
| gap is at or under the baseline | `green` |
| gap is over the baseline, up to and including two times the baseline | `amber` |
| gap is over two times the baseline | `red` |

Example: a monthly-cadence client (baseline 30) with a 45-day gap is `amber`. The same 45-day gap on a weekly-cadence client (baseline 7) is well past two times the baseline and is `red`.

## Step 4: the `needs input` tier

If both contact dates are `unknown`, the tier is `needs input`. The script never guesses a tier here and never defaults to `green`. A client sitting in `needs input` is simply unscored, and that alone says nothing about whether the account is actually at risk. Get real dates from the owner before the next review cycle.

## Recalibrating the thresholds

The 1x / 2x multiplier above is a starting point. Adjust it once a quarter (Phase 3 of the main procedure): walk the ledger with the owner and check which flags turned into real conversations or saved accounts, and which were noise, a client who was simply busy rather than pulling away. If a client's real-world cadence does not match their stated tier (a "monthly" client who in practice talks to the owner every six weeks without ever being at risk), either correct the cadence word in `contract_value_or_tier` or note a client-specific override in `notes`, for example: `cadence override: 45 days, not 30, confirmed with owner 2026-10-01`. The script does not read overrides automatically; apply them by adjusting the cadence word or by treating that client's score as advisory until the schema is extended.
references/cadence-rules.md3,653 chars
# Cadence rules

The timing rules the whole system runs on: how fast the owner replies, how often a touch goes out, when a gap resets, and when the system stops drafting on its own.

## 1. Response-time rule

Any message a client sends gets a reply from the owner the same business day. This is separate from proactive outreach; it is the floor for reactive response.

Default window: by the end of the business day the message arrives. If a narrower window fits the owner's schedule better (within four hours, before end of morning), set that instead and record it here as the working definition for this owner:

> Current rule: reply by ______ on the same business day a client message arrives.

This rule is checked at the start of every weekly review (main procedure, step 7): any client message from the past week that did not get a same-day reply gets logged in that client's `notes` field as `late-reply: YYYY-MM-DD`, the same format as the no-reply log in §4 below. The log turns a one-week observation into a visible history for that client. Walk the log during the quarterly cadence audit (main procedure, step 17) to see whether a client's late replies are a one-off slip or a recurring pattern worth changing the window itself.

## 2. Touch intervals

Any client sitting at `amber` or `red` after a review gets a drafted touch that same cycle (see the main procedure, Phase 2). The weekly review cadence itself sets the interval, so there is no extra wait-and-see period layered on top of it. A client does not get touched more than once between reviews unless the owner asks for it directly.

## 3. Reset rule

A client-initiated contact (an email, a reply, a call, any message where the client spoke first) resets the gap clock the day it happens. Update `last_client_initiated_contact_date` to that date immediately, the same day it happens, rather than waiting for the next weekly review. This removes the client from the current cycle's flagged list even if the owner has not yet replied to that message (the same-day response rule above still applies on its own).

An owner-initiated touch that goes out and gets approved also resets the clock. Update `last_owner_initiated_contact_date` only on the day it is actually sent, once it has actually gone out, and set `next_scheduled_touch_date` to the date of the client's next weekly review at the same time (main procedure, step 11).

## 4. Stop condition: two unanswered touches

Track unanswered touches in the ledger's `notes` field using this exact format so it is easy to scan:

```
no-reply: 2026-09-12, 2026-10-03
```

Each date is a touch that was sent and approved but got no client reply by the next weekly review. Once a client has two such dates logged with no reply in between, stop drafting further proactive touches for that account automatically. Flag the client for the owner's manual review instead:

> [Client name] has had two proactive touches with no reply (dates in notes). This may be normal (a slow season, an out-of-office) or it may mean the relationship needs a different approach than another check-in. What do you want to do here?

Do not resume automatic drafting for that client until the owner says to, and record their decision in `notes`.

## 5. What does not reset or stop the clock

- A touch that was drafted but not approved by the owner does not count as contact and does not update any date.
- A touch that bounced, was never sent, or sat in a queue does not count.
- The quarterly cadence audit (Phase 3 of the main procedure) can change a client's cadence baseline going forward, but it does not retroactively erase a logged unanswered touch.
references/touch-templates.md3,208 chars
# Touch templates

Four drafts, one per trigger type. Pick the one that matches what is actually true about the client right now. Fill only with details that exist in the ledger, the owner's notes, or what the owner supplies in the current conversation. Never invent a result, an event, or a renewal date.

Every draft follows the same rule: check-in, not a pitch. No upsell, no ask, unless the owner specifically wants one folded in for this client.

## Template A: routine check-in (no particular news)

Use when nothing specific has happened since the last contact, the gap has simply grown past the client's cadence baseline.

```
Subject: Checking in

Hi [client name],

It's been a few weeks since we last talked, wanted to check in and see how things are going on your end with [project/account detail from the ledger].

Anything come up I should know about, or anything you need from me right now?

[owner name]
```

## Template B: a result to share

Use when there is a concrete, finished deliverable, metric, or milestone to point to. This is the strongest version of a check-in because it hands the client something concrete to respond to.

```
Subject: [specific result] is done

Hi [client name],

Wanted to flag that [specific result: a deliverable name, a number, a milestone] is finished. [one sentence on what it means for them, concrete, no filler].

Let me know if you want to walk through it or if anything needs adjusting.

[owner name]
```

## Template C: response to a confirmed life or business event

Use only when the owner has independently confirmed a real event (a hire, a move, a product launch the client mentioned, a public announcement). Never invent or infer this from silence; "they have gone quiet" is not itself an event to reference.

```
Subject: Saw the news about [confirmed event]

Hi [client name],

Saw [confirmed event, stated plainly, no guessing at details not confirmed]. Congratulations, that's a big one.

Wanted to check in and see if anything on our side needs to shift because of it, or if you just wanted a quiet week, either way works.

[owner name]
```

## Template D: renewal-adjacent check-in

Use when the client's contract or engagement is approaching a natural renewal point (pull the date from the ledger or ask the owner if it is not recorded).

```
Subject: Checking in ahead of [renewal period]

Hi [client name],

With [renewal period, e.g. "the end of the quarter" or an actual date] coming up, wanted to check in before then rather than have it be a surprise conversation.

How are you feeling about how things have been going? Anything you'd want different going forward?

[owner name]
```

## Filling rules (apply to all four)

1. Pull `[client name]` and any project detail straight from `client_name` and `contract_value_or_tier` in the ledger, or from the owner's own words this turn.
2. Never state a result, number, or event the owner has not confirmed is true and finished.
3. Keep the subject line plain and literal. No urgency language, no exclamation points.
4. If the owner wants an ask folded in (a renewal pitch, an upsell) for a specific client, add it as its own short paragraph at the end, kept separate from the check-in.
examples/contact-ledger-example.md1,435 chars
# Contact ledger, worked example

A filled ledger as of 2026-10-27, for a one-person consulting shop with seven active clients across three cadence tiers. Use this as the pattern when you build a real one; the field order and date format match `references/contact-ledger-schema.md` exactly.

| client_name | contract_value_or_tier | last_owner_initiated_contact_date | last_client_initiated_contact_date | next_scheduled_touch_date | risk_tier | notes |
|---|---|---|---|---|---|---|
| Dana Whitfield | Monthly retainer, $1,200/mo | 2026-09-10 | 2026-09-02 | | amber | gap computed 2026-10-27 run |
| Marcus Oduya | Weekly retainer, $2,000/mo | 2026-10-20 | 2026-10-22 | | green | replies fast, easy client |
| Priya Chandrasekaran | Quarterly project, $6,500 | 2026-04-10 | unknown | | red | last big deliverable shipped in April, no word since |
| Hal Jennison | Monthly retainer, $950/mo | 2026-08-01 | 2026-08-01 | | red | no-reply: 2026-09-08, 2026-10-01 (flagged for manual review per cadence-rules.md) |
| Sofia Reyes | Biweekly retainer, $1,800/mo | 2026-10-24 | 2026-10-24 | | green | new client, onboarded September |
| Tom Okafor | Monthly retainer, $1,100/mo | unknown | unknown | | needs input | inherited from a referral, no history logged yet, ask Tom directly or check old email |
| Lindsey Marr | Quarterly project, $4,000 | 2026-09-05 | 2026-09-30 | 2026-12-01 | green | renewal conversation set for early December |
examples/filled-checkin-example.md2,534 chars
# Worked example: one client, start to finish

This walks one row from `examples/contact-ledger-example.md` through a full weekly review cycle: scoring, template selection, drafting, approval, and the ledger update afterward.

## Client: Dana Whitfield

**Ledger row before this cycle (as of 2026-10-27):**

| Field | Value |
|---|---|
| client_name | Dana Whitfield |
| contract_value_or_tier | Monthly retainer, $1,200/mo |
| last_owner_initiated_contact_date | 2026-09-10 |
| last_client_initiated_contact_date | 2026-09-02 |
| next_scheduled_touch_date | (blank) |
| risk_tier | amber |
| notes | (blank) |

## Step 1: scoring (per `references/risk-scoring.md`)

- Cadence word found: "monthly," baseline is 30 days.
- Effective last contact: the later of the two dates, 2026-09-10.
- Gap as of 2026-10-27: 47 days.
- 47 is over the 30-day baseline but not over 60 (two times baseline), so the tier is `amber`. This matches `scripts/compute_initiation_gap.py`'s output for this row.

## Step 2: template selection (per `references/touch-templates.md`)

No specific finished deliverable is on record for Dana this month, and no confirmed life or business event, and no renewal window is close. This is a plain gap. Template A, routine check-in, is the fit.

## Step 3: drafted touch

```
Subject: Checking in

Hi Dana,

It's been a few weeks since we last talked, wanted to check in and see how things are going on your end with the retainer work.

Anything come up I should know about, or anything you need from me right now?

[owner name]
```

## Step 4: presented to the owner

> **Dana Whitfield, flagged amber (47-day gap, 30-day baseline).** Draft above, routine check-in, no specific news to reference. Send as-is, edit, or skip this cycle?

## Step 5: owner's response (example)

The owner approves the draft without changes.

## Step 6: ledger update after approval

| Field | Value after update |
|---|---|
| last_owner_initiated_contact_date | 2026-10-27 (the date it was actually sent) |
| next_scheduled_touch_date | 2026-11-03 (the date of Dana's next weekly review, per step 11) |
| risk_tier | will recompute to `green` on the next run, since the gap resets to 0 |
| notes | unchanged, no prior unanswered touches logged for this client |

## If the owner had said "skip this one" instead

The row stays exactly as it was, `risk_tier` stays `amber`, and no penalty or re-flag happens until the next cycle's review, per `references/cadence-rules.md`. Nothing is sent, scheduled, or queued without that explicit yes.
scripts/compute_initiation_gap.py5,489 chars
#!/usr/bin/env python3
"""
compute_initiation_gap.py

Reads a contact ledger CSV (fields defined in references/contact-ledger-schema.md)
and prints a risk-scored, sorted report. Optionally writes the computed
risk_tier back into an output CSV.

No network calls, no third-party packages: standard library only.

Usage:
    python3 compute_initiation_gap.py ledger.csv
    python3 compute_initiation_gap.py ledger.csv --as-of 2026-10-27
    python3 compute_initiation_gap.py ledger.csv --write scored_ledger.csv

CSV columns expected (header row required, this exact spelling):
    client_name
    contract_value_or_tier
    last_owner_initiated_contact_date   (YYYY-MM-DD or "unknown")
    last_client_initiated_contact_date  (YYYY-MM-DD or "unknown")
    next_scheduled_touch_date           (YYYY-MM-DD or blank)
    risk_tier                           (overwritten by this script)
    notes

Scoring logic mirrors references/risk-scoring.md exactly. If that file
changes, update the CADENCE_BASELINES and the tier thresholds below to match.
"""

import argparse
import csv
import sys
from datetime import date, datetime

CADENCE_BASELINES = [
    ("biweekly", 14),
    ("bi-weekly", 14),
    ("weekly", 7),
    ("quarterly", 90),
    ("monthly", 30),
]
DEFAULT_BASELINE_DAYS = 30

REQUIRED_FIELDS = [
    "client_name",
    "contract_value_or_tier",
    "last_owner_initiated_contact_date",
    "last_client_initiated_contact_date",
    "next_scheduled_touch_date",
    "risk_tier",
    "notes",
]

TIER_SORT_ORDER = {"red": 0, "amber": 1, "green": 2, "needs input": 3}


def parse_date(value):
    """Return a date object, or None if the value is unknown/blank/unparseable."""
    if value is None:
        return None
    value = value.strip()
    if not value or value.lower() == "unknown":
        return None
    try:
        return datetime.strptime(value, "%Y-%m-%d").date()
    except ValueError:
        print(
            f"warning: could not parse date '{value}', treating as unknown",
            file=sys.stderr,
        )
        return None


def baseline_days(tier_text):
    """Find the cadence baseline, in days, from the free-text tier field."""
    lowered = (tier_text or "").lower()
    for keyword, days in CADENCE_BASELINES:
        if keyword in lowered:
            return days
    return DEFAULT_BASELINE_DAYS


def score_row(row, as_of):
    owner_date = parse_date(row.get("last_owner_initiated_contact_date"))
    client_date = parse_date(row.get("last_client_initiated_contact_date"))

    if owner_date is None and client_date is None:
        return "needs input", None

    effective_dates = [d for d in (owner_date, client_date) if d is not None]
    effective_last_contact = max(effective_dates)
    gap_days = (as_of - effective_last_contact).days

    baseline = baseline_days(row.get("contract_value_or_tier"))
    if gap_days <= baseline:
        tier = "green"
    elif gap_days <= 2 * baseline:
        tier = "amber"
    else:
        tier = "red"
    return tier, gap_days


def load_rows(path):
    with open(path, newline="", encoding="utf-8") as f:
        reader = csv.DictReader(f)
        missing = [field for field in REQUIRED_FIELDS if field not in (reader.fieldnames or [])]
        if missing:
            sys.exit(
                "error: ledger is missing required column(s): "
                + ", ".join(missing)
                + "\nSee references/contact-ledger-schema.md for the exact field list."
            )
        return list(reader), reader.fieldnames


def main():
    parser = argparse.ArgumentParser(description="Score a contact ledger's initiation gaps.")
    parser.add_argument("ledger_csv", help="Path to the contact ledger CSV file.")
    parser.add_argument(
        "--as-of",
        help="Date to score against, YYYY-MM-DD. Defaults to today.",
        default=None,
    )
    parser.add_argument(
        "--write",
        help="Optional path to write a copy of the ledger with risk_tier filled in.",
        default=None,
    )
    args = parser.parse_args()

    as_of = parse_date(args.as_of) if args.as_of else date.today()
    if as_of is None:
        sys.exit(f"error: could not parse --as-of value '{args.as_of}', expected YYYY-MM-DD")

    rows, fieldnames = load_rows(args.ledger_csv)

    scored = []
    for row in rows:
        tier, gap_days = score_row(row, as_of)
        row["risk_tier"] = tier
        scored.append((row, gap_days))

    # Sort: red, amber, green, needs input; within a tier, longest gap first.
    def sort_key(item):
        row, gap_days = item
        tier_rank = TIER_SORT_ORDER.get(row["risk_tier"], 99)
        gap_rank = -(gap_days if gap_days is not None else -1)
        return (tier_rank, gap_rank)

    scored.sort(key=sort_key)

    print(f"Risk report as of {as_of.isoformat()}\n")
    header = f"{'Client':<28} {'Tier':<12} {'Gap (days)':<12} Notes"
    print(header)
    print("-" * len(header))
    for row, gap_days in scored:
        gap_text = str(gap_days) if gap_days is not None else "n/a"
        print(f"{row['client_name']:<28} {row['risk_tier']:<12} {gap_text:<12} {row.get('notes', '')}")

    if args.write:
        with open(args.write, "w", newline="", encoding="utf-8") as f:
            writer = csv.DictWriter(f, fieldnames=fieldnames)
            writer.writeheader()
            for row, _ in scored:
                writer.writerow(row)
        print(f"\nWrote scored ledger to {args.write}")


if __name__ == "__main__":
    main()

Sources

Merged from the top existing skills on the topic, with credit to every input.

Frequently asked questions

How do I know if a client is quietly pulling away?

Look at who initiated the last few contacts, more than how the client sounded in them. If you've been the only one sending messages for weeks and the gap keeps growing past that client's usual rhythm, that's the warning sign.

I keep noticing I'm always the one reaching out. Is that normal?

It's common, and it's exactly what this skill tracks: if you're always the one reaching out while the other side stays passive, that imbalance is the first readable sign of disengagement, often before the client ever says anything.

What should I actually say when I reach out to a quiet client?

Keep it short and no-agenda: a real update or just a plain check-in. Skip anything that reads like a renewal pitch or a sales nudge. If you want to fold in an ask for a specific client, put it in its own short paragraph at the end, separate from the check-in.

How often should I review which clients have gone quiet?

Weekly is enough for most owner-run books of business. Daily is noise, monthly is often too late to catch the gap before it becomes permanent silence.

What if a client doesn't respond to my check-in either?

One unanswered check-in just means try again at the next cadence. Two in a row is where the skill stops drafting automatically and hands the account to you for a manual call.

Can I just rely on memory instead of tracking this?

If you can't say when a client last reached out to you first, you're relying on a feeling. Writing the last-contact dates down replaces the feeling with a number.