name: session-recap description: Drafts the CLIENT-FACING email after an advisory session from the approved ledger, shaped by the actual correspondence with that client and gated by cross-client-check then kathryn-email-voice. Drafts only, never sends. Use in Phase B when the session had an external party and something is owed in either direction. Not to be confused with internal-record, which is advisor-only.
Session recap
The email to the client. A draft. Always a draft.
The recap is RENDERED, never hand-written
python3 render_recap.py
This skill authors the JSON. render_recap.py authors the email. Every line the client reads comes out of ledger.json and the engagement's last-sent-, placed by the renderer. Nothing on this page is a licence to type a body.
Hand-authoring the body is a failed run. Not a shortcut, not a special case for a session that seemed simple — a failed run. Say so and re-run it.
Why, in Kathryn's own words from the renderer she built for Crulliance on 2026-08-10:
"The recap was the last load-bearing document still produced by a prompt that re-read the JSON and rewrote it from scratch every Monday. That is untestable by construction: same input, different output, and the only way to find out it was wrong was to ship it."
The 2026-08-18 Tracy Beveridge run proved it a second time: eight rewrites in one day, eight different emails, and no way to tell which one had drifted. A renderer gives the same email for the same ledger, every time, and a fix lands in the ledger where the next run inherits it.
So when the draft is wrong, fix the ledger and re-render. Editing the body leaves the ledger wrong, and the next run reproduces the error. verify_run.py hashes the rendered body and fails the run if it changed after the gates cleared it, so a hand edit does not pass quietly.
What comes from where:
| Thing | Source |
|---|---|
| Client name, recipients, register | engagement.json |
| Section order, section names, opener form, close form, subject | last-sent-, derived from her real sends |
| Every fact, every ask, every agenda line | ledger.json, filtered by audience |
The renderer refuses rather than filling a gap — no client-facing wins with no noWinsReason, a required section with no rows, an opener with nothing to open on. Each refusal prints a fix: line. Fix the data, never the renderer.
The rest of this page is how to get the ledger and the shape spec right, and what the gates check. It is not an alternative route to a body.
Skip it when
internal-session-no-external-party— nobody outside the practice to write tothin-session— a short note instead, no headers, no manufactured structureno-open-commitments— nothing was decided and nothing is owed in either directionbackfill-already-recapped— a past session processed for the record. It was recapped at the time, or a later send has overtaken it. Name what was already sent, and when, in the report.
Say which, in the manifest, using the exact reason string.
Recipients
From engagement.recapRecipients in the ledger. Where an engagement has two principals and only one was in the session, the recap goes to that principal and the split gets flagged. Never assume a Cc from a prior send — a recipient list changes and a prior email is not authority.
Shape — read the correspondence, not a frozen golden
There is no global recap format, and imposing one is how a facilitation client gets a cohort-shaped email. Neither is there a frozen golden: a golden captured once goes stale the moment the relationship moves, and it cannot tell you what has already been said.
Before drafting, search the mail for this client. Three passes, all of them:
| Pass | Why |
|---|---|
| Sent | The shape. What Kathryn's last few recaps to this recipient actually looked like — length, headers, register, sign-off. The most recent send wins over any older example |
| Drafts | What is already written and unsent. Never draft a second one alongside it — amend or replace, and say which |
| Inbox | What they have already told you. A recap that re-asks for something they sent last week reads as not having read it |
Search is unreliable on threads — once a search returns hits, fetch each message by id rather than trusting the result set. Where nothing is found, say so in the report; do not silently fall back to a house format.
Read the inbox pass against the ledger's commitment rows. Anything the client has already delivered comes out of "What I need from you" and goes into "What changed" — a delivered item re-requested is the single most common way a recap loses trust.
The correspondence outranks everything below. Section order, section names, register and close all come from what she actually sends this recipient. The default below is what to use when the correspondence is silent — it is not a house style to impose over evidence.
Where the engagement record carries recapShape.sectionOrder or recapShape.headers, those were derived from her real sends and they win over this list without further argument.
Write what you find into last-sent- — the shape spec the renderer reads. It lives next to the engagement record (qc/last-sent-) and follows the field pattern of aos-client-rc/qc/last-sent-ruben.json: greetingnames, toorder, cc, sections, source, shapesource, shapesourcetype, shapenotes. Record what you inferred as inferred. shapesource_type must be sent or approved-draft — the renderer refuses without it, because a version sent by mistake must not become the specification.
Then the shape is data, checkable and diffable, instead of a paragraph somebody has to remember. render_recap.py --check re-reads a rendered or sent email against it.
Default order, when the correspondence shows nothing:
- One line on what this is
- What went well — the client's own results and wins, in their terms
- What we decided — only rows with
status: decided - What changed in your documents — for anything in
documentsTouched, what changed, not a link - What I owe you — Kathryn's commitments, with their date state carried through
- What I need from you — the client's commitments, with their date state carried through
- Next session
- One closing line
Use her section names, not these. These are descriptions of content. If her sends call it "Wins & Progress" and "What We Covered" and "Tracy's Action Items" and "Kathryn's Action Items", those are the headers. Inventing a header she has never used — "Two Things Still Open", "What I'll Do" — is the same failure as inventing an order.
Every proper noun is checked against reference data — check_nouns.py enforces it.
python3 check_nouns.py
It blocks on two things, and the second is the one judgment misses: a noun that is transcript-only, and a noun that IS in reference data on a row that retires it — replaced, superseded, ruled out, former. Being in the file is not the same as being current. On 2026-08-19 a recap told Tracy the remote-in path would be tested "from Aruba"; she never said it, and her reference data reads "Aruba — replaced by Punta Cana in 2026." The QC block claimed the nouns were verified. Three gates passed it. Unknown-but-plausible nouns warn rather than block, so the output stays worth reading.
One fact, one home — and check_sections.py enforces it.
py checksections.py
Run it before the gates. Exit 0 = clear, exit 1 = blocked with the repeated phrases and the sections they span. verify_run.py runs it too, so a duplicated recap cannot pass the run. Fix the draft, never the checker.
A fact appears in exactly one section. The wins section carries the outcome; the covered section carries the mechanism. Never both, and never in the same words. Run the check before the gates: take each proper noun and each concrete fact in the draft and count its sections. Anything landing in two has to lose one.
When a session's only outcomes ARE the work, the wins section is not a second copy of it. A setup session, a rebuild, a migration — the connectors that survived, the tasks that were re-registered, the files that were found are all mechanism, and they belong in the covered section. The wins section carries what the client did or gained on their own: their numbers, their boundaries held, the thing they pulled off in the week. If the only candidates left are restatements of the covered items, that is a session with no client wins in it — write a shorter wins section, or none. Padding it by echoing is how a 570-word recap says five things twice. Recorded 2026-08-19, after a re-run did exactly that.
The opener may echo the top win. That is hers — "Good to hear you're 40 returns ahead of last year" over a wins bullet reading "40 returns ahead of same day last year" (4/27). Do not over-correct it away. The rule above is about the body's sections, not the opener.
Never quarantine the session's substance into a section of your own. Her recaps carry the real content of the hour inside the covered/decided section, numbered, each item a title and the reasoning under it. A draft that moves the hard parts into a separate invented block has restructured her, which the voice skill forbids outright.
Rules
Only stated and decided content. A shaping outcome is not a decision. A hedged commitment either appears hedged in the client's own words or does not appear.
Date states carry through. A stated date appears as stated. A vague one gets a proposed date, and the proposal is named as a proposal. A none asks for one. Never silently invent.
Documents worked live get "what changed," not a link dump. They were edited in front of the client; a bare link tells them nothing about what to look at.
Chat content is reproduced in full, in the section where the client acts on it. Never "the list from our chat" — the chat panel is gone for them.
A scope, price or timeline item gets one holding line, or nothing. A holding line names the question, gives a date for the answer, and contains no number and no yes. If the client did not raise it directly, leave it out entirely and flag it. Silence on a question they asked directly reads as evasion; a number reads as a commitment. The holding line is the only safe middle.
Register comes from the engagement record, then from the correspondence — never from this page. Read engagement.register if it is set. A succession or facilitation engagement with attorneys and bankers downstream reads plain and peer-to-peer. A long-running membership 1:1 with someone who has known Kathryn for years reads warm, and her sends to that person will show it.
Where the record is silent and the correspondence is silent, default to: no coaching language, no motivational framing, no gratitude opener, no flourish close. Peer to peer, specific, plain.
These defaults never override a documented pattern. Kathryn's recaps to a warm-register recipient open "Glad we…" / "Good to hear…" on the client's own win, and close on the next contact or the client's trip — "See you Thursday!", "Enjoy Punta Cana." Applying the plain default over three sends that do the opposite produces an email she will not send. Recorded 2026-08-19, after four drafts to Tracy Beveridge failed for exactly this reason.
The failure this section exists to prevent: this skill opens by warning that imposing a global format is how a facilitation client gets a cohort-shaped email. A global tone does the same damage one layer down, and it is harder to see because every individual sentence is defensible.
No unconfirmed proper noun. No contradiction-flagged fact. No advisor note. Ever.
Two gates, before the draft is written — not after
This is client-facing copy. It does not leave this skill without both, in this order.
1. cross-client-check — runs first. Kathryn holds knowledge from every other client and member. This catches what her own memory cannot: another client's situation, group or cohort state, rankings, anything from a different 1:1, and her private assessments of this recipient. The ledger will not flag these, because each fact is individually true — the defect is only visible when you ask who else it came from.
2. kathryn-email-voice — runs second, on the output of the first. Voice, register, and the mechanical bans it enforces with check.py. The rules written into this skill are the shape of the email; that skill is the sound of it, and it is the authority where the two disagree.
Neither gate is a pass over a finished draft. A draft that has not been through both is not finished, and run-report records that it ran them.
They run again on every rewrite. A revised body is a new draft, not a tuned one — re-run both, in order, and re-stamp. Applying them from memory because you read them an hour ago is the same as skipping them. verify_run.py now enforces this: it hashes the email body the gates attested to, and fails the run if the body changed afterwards.
kathryn-email-voice Step 2 is not optional and this skill does not exempt it. It reads her three most recent sends to this recipient in full and the most recent transcript for cadence. A Phase B seam that bars re-reading the transcript for facts does not bar reading it for cadence — and where the two appear to disagree, this skill has already named voice the authority. Recording transcript NOT READ in the QC block instead of doing the read is a skipped step written down, not a step performed.
Output
Render first, then gate, then publish:
python3 render_recap.py— writes the body under the--into /recap-draft.md ## Email bodymarker, replacing whatever was there.checksections.pyandchecknouns.pyonrecap-draft.md.cross-client-check, thenkathryn-email-voicewith itscheck.py. Paste both blocks into the header ofrecap-draft.md, above the marker.- Stamp
gates.jsonwith the sha256 of the body the gates cleared. python3 render_recap.pyfor the Gmail draft — real markup, not escaped entities.--html --stdout
If ~~email is connected, create it as a draft to the named recipients — one draft, amended in place, never a second one alongside. Either way recap-draft.md carries the ledgerId.
A rewrite is a new draft. Re-render, re-run both gates in order, re-stamp. Never edit the body and re-use the old signatures.
Never send. Sending is a decision a person makes after reading.