Jev call copilot CurrencyTransfer research and architecture built 2026-09-24

Reference dissection: jev-sales-copilot (moritzkremb)

Source tree: /home/stevan/dev/jev/reference/jev-sales-copilot (all paths below are relative to it unless absolute). Every source, data, test, eval and doc file was read in full (uv.lock skipped). Line references are file:line and were verified with grep -n against the checked-out tree on 2026-09-24.

Where a claim comes from a reported run (not something I executed), it is labelled reported. Where I am inferring, it is labelled assumption.

0. Reading map

File Lines Role
copilot/constants.py 375 Model pin, the 37 questions, weights, priors, every threshold, LLM settings, the two verification questions. The whole "policy" of the app.
copilot/engine.py 484 CallSession: state window, feature extraction, speaker masking, composite + EMA, fact persistence, objection state machine, coaching gate, talk ratio / pace, sensitivity, recompute-without-Jev.
copilot/jev_client.py 132 Async wrapper over typesafe_sdk.AsyncTypeSafeClient.system_one; SDK answer objects to plain dicts; cost accounting.
copilot/personalize.py 221 Optional tailored line: trigger rule, Haiku prompt, output cleaning, Jev verification.
copilot/server.py 308 FastAPI: static UI, /api/*, one /ws per tab; replay task, live utterances, weight changes, background personalize task.
copilot/replay.py 68 Load calls from JSON or rep:/prospect: text.
copilot/cli.py 116 Headless replay to terminal / eval/timelines.json.
data/playbook.json 170 15 moves: id, title, what, not_for, 3 phrasings.
data/calls/{good,bad,mixed}.json 46-50 Hand-written 38-42 turn calls for a field-service SaaS ("Relay").
data/calls/cloudtalk.json 58 Real recorded CloudTalk BANT call, 48 turns, t/t_end aligned to YouTube uovWCGCl2-s.
static/index.html 552 Single-page UI (Chart.js), WebSocket client, Web Speech API, YouTube IFrame sync.
tests/*.py 999 Unit (mock Jev), server (mock), personalize (mock LLM), integration (real API), e2e (real API).
eval/integration_report.json 820 Last integration run: 56/56 checks, per-case observed answers.
eval/timelines.json 7878 Last e2e run: per-utterance probability, signals, sensitivity, latency, tokens for the three calls.
README.md, DEMO.md 218, 107 Architecture write-up; 3-5 min demo script with utterance numbers.

Runtime: Python >= 3.13, typesafe-sdk>=0.6.0, FastAPI, uvicorn, httpx, pyyaml>=6.0.3, websockets>=17.1 (pyproject.toml:6-13). No build step. run.sh resolves keys env > .env > ~/.clawdia-secrets/.env and never prints them (run.sh:11-32); binds 127.0.0.1 only (run.sh:43).

The one-paragraph version of how it works: each utterance triggers exactly one Jev request carrying a compact JSON state (12-turn window + a few call facts) and 37 typed questions; Jev never generates text; all numbers (closing probability, talk ratio, fact persistence, objection state) are arithmetic in engine.py over Jev's typed answers; the coaching card is a Jev Choice over a hand-written 15-move playbook and code shows the pre-written text; an optional Haiku call rewrites one line off the critical path and Jev is then asked whether that line invents a fact. Pinned to jev-1.13.0 (constants.py:16).


1. Per-utterance state sent to Jev

Built by CallSession.build_state() (engine.py:185-200), called once per ingested utterance (engine.py:203-213).

Exact shape:

{
  "call_facts": {
    "duration_min": 5.7,
    "talk_ratio_rep": 0.54,
    "stage_history": ["opening", "discovery", "pitch_demo"],
    "known_facts": ["budget_discussed", "pain_identified"],
    "objection": {"open": true, "type": "price"}
  },
  "recent_transcript": [
    {"t": "05:12", "speaker": "rep", "text": "..."},
    "... up to 12 entries, the newest is the latest utterance ..."
  ],
  "latest_utterance": {"t": "05:45", "speaker": "prospect", "text": "..."}
}

Field by field:

Field Source Notes
call_facts.duration_min round(latest.t / 60, 1) (engine.py:190) t is seconds since call start; for live/typed input with no t, engine estimates prev.t + max(2.0, words/2.5) (engine.py:205).
call_facts.talk_ratio_rep talk_ratio(self.utterances) over the whole call, rounded 2dp (engine.py:187,191) Rep words / total words; 0.5 when no words (engine.py:30-40).
call_facts.stage_history _compress(stage_history)[-6:] (engine.py:192, engine.py:478-484) Consecutive duplicates collapsed, last 6 distinct runs. Stage comes from Jev's previous stage answers.
call_facts.known_facts sorted(self.facts) (engine.py:193) Names only, no values or timestamps. Can include competitor_mentioned (see section 4).
call_facts.objection {"open": true, "type": type or "unknown"} or {"open": false} (engine.py:194-196)
recent_transcript self.utterances[-12:] (engine.py:186, RECENT_WINDOW = 12 at constants.py:24) Includes the latest utterance as its last element. Each entry is Utterance.to_state() = {"t": "MM:SS", "speaker", "text"} (engine.py:122-123, format_ts at engine.py:105-107).
latest_utterance Same to_state() of the newest utterance (engine.py:199) Duplicated deliberately so questions can point at latest_utterance.text by name.

Speaker handling: speakers are the literal strings "rep" and "prospect" (engine.py:116-119). The server normalises anything starting with p to prospect, else rep (server.py:288). Replay lowercases the JSON's speaker (replay.py:50). There is no diarization anywhere: replay/video calls carry speaker labels in the JSON, live mic uses a manual toggle (section 8).

Size: reported 7,189-8,121 input tokens per request across the e2e run (eval/timelines.json, computed over all rows), ~7.4-7.5k in the integration cases (eval/integration_report.json:29,72,...). README puts it at "~7,500 input tokens per request, ~$0.0003". Almost all of that is the question bank (the 15 speculative phrasing Choices alone add ~1,900 tokens per README "Limitations"), not the state.

The unit test pins the shape: exactly 12 transcript entries once the call is longer than that, and call_facts keys are exactly {duration_min, talk_ratio_rep, stage_history, known_facts, objection} (tests/test_engine_unit.py:283-292).

What Jev does not see: the full call, any fact values (only fact names), any timing between turns beyond the MM:SS stamps, the closing probability, the previous coaching move, or any product/company knowledge. All domain knowledge lives in the question texts and the playbook option descriptions.


2. The question bank, verbatim

QUESTIONS is a dict at constants.py:117-258 plus a loop that appends 15 phrasing::<move> Choices (constants.py:261-268). Total = 16 Nouls + 3 Scores + 3 Choices + 15 phrasing Choices = 37 questions, all sent in every request (speculative fan-out). Ids are never sent to the model; each instructions is complete and names the state path it reads (enforced by tests/test_engine_unit.py:332-343).

Helper _noul(question, true, false) builds {"type": "noul", "instructions": {"question": ...}, "criteria": {"true": ..., "false": ...}}; criteria are omitted when neither is given (constants.py:110-114). _PROSPECT = "spoken by the prospect (\latest_utterance.speaker` is prospect)" (constants.py:107`).

2a. Prospect-turn Nouls (masked to 0 in code when speaker is rep; PROSPECT_ONLY_SIGNALS at constants.py:271-280)

buying_signal (constants.py:119-123)

commitment (constants.py:124-128)

next_step_agreed (constants.py:129-133)

prospect_objecting (constants.py:134-138)

prospect_accepts (constants.py:139-143)

prospect_disengaging (constants.py:144-148)

buyer_confused (constants.py:149-153)

prospect_asked_price (constants.py:154-156) - no criteria; note the question text does not name the speaker, and because prospect_asked_price is not in SIGNAL_WEIGHTS the engine never masks or reads it (see 2g). A port that promotes it to a weighted signal must add the speaker condition to the question text or rely on the PROSPECT_ONLY_SIGNALS mask.

2b. Rep-turn Nouls (masked to 0 when speaker is prospect; REP_ONLY_SIGNALS at constants.py:281)

rep_discovery_question (constants.py:158-162)

rep_pitching (constants.py:163-167)

2c. Either-speaker Nouls (not masked)

next_step_proposed (constants.py:168-172)

competitor_mentioned (constants.py:194-198)

2d. Transcript-window Nouls (durable facts; read recent_transcript, so they can fire on a rep turn)

pain_identified (constants.py:174-178)

budget_discussed (constants.py:179-183)

decision_maker_identified (constants.py:184-188)

timeline_known (constants.py:189-193)

2e. Scores (3 levels each; criteria is a list of {summary, signals[]})

rapport (constants.py:200-211)

engagement (constants.py:212-223)

urgency (constants.py:224-234)

2f. Choices

stage (constants.py:236-242; options = STAGES at constants.py:46-79, each {what, not_for})

objection_type (constants.py:243-249; options = OBJECTION_TYPES at constants.py:93-101, each {what} only)

next_move (constants.py:250-257; options = every playbook move's {what, not_for})

phrasing::<move_id> x15 (constants.py:261-268)

2g. Which questions are actually consumed

Consumer Questions
Weighted into probability (SIGNAL_WEIGHTS, constants.py:293-317) commitment, buying_signal, prospect_accepts, next_step_agreed, pain_identified, budget_discussed, decision_maker_identified, timeline_known, engagement, rapport, urgency, prospect_disengaging, buyer_confused, competitor_mentioned, rep_pitching, rep_discovery_question
Objection state machine (engine.py:273-297) prospect_objecting, objection_type, prospect_accepts, buying_signal, commitment, next_step_agreed, prospect_disengaging
Stage (engine.py:323-329) stage
Coaching (engine.py:299-320) next_move, phrasing::*
Fact persistence (engine.py:257-271) the 5 fact-kind signals + competitor_mentioned
Asked but only read by the integration test prospect_asked_price, next_step_proposed (neither is in SIGNAL_WEIGHTS, so extract_features and the UI signals map never touch them; they exist as labels for tests/test_integration_jev.py:63-178)

Masking is implemented in extract_features (engine.py:234-237; the adjacent 238-239 is the separate persisted-fact clamp to 1.0) and _update_facts (engine.py:264-265), and only for signals that are in self.weights, so prospect_asked_price being listed in PROSPECT_ONLY_SIGNALS has no runtime effect. Unit test: tests/test_engine_unit.py:156-166 and :146-153.


3. Closing-probability maths

All in engine.py; the formula is documented in the comment block at constants.py:284-292.

3a. Composite (composite(), engine.py:61-81)

inst = clamp( STAGE_PRIOR[stage] + sum_i contribution_i , P_MIN, P_MAX )

contribution_i =
   w_i * x_i                      for kind in {noul, fact, code}, x in [0,1]
   w_i * (x_i - 0.5) * 2          for kind == score, x = score/(levels-1)   (middle level = 0)

Missing features are skipped; an unknown stage falls back to INITIAL_PROBABILITY (0.30) as the prior (engine.py:70,73-79). normalize_score is score/(levels-1), clamped (engine.py:55-58); for a 3-level score, level 1 contributes 0, level 0 contributes -w, level 2 contributes +w.

STAGE_PRIOR (constants.py:82-91): opening 0.25, discovery 0.30, qualification 0.35, pitch_demo 0.35, objection_handling 0.30, pricing 0.38, closing 0.45, next_steps 0.42.

SIGNAL_WEIGHTS (constants.py:293-317), 18 entries:

signal kind weight label
commitment noul +0.15 Prospect committed
buying_signal noul +0.10 Buying signal
prospect_accepts noul +0.03 Prospect agrees with rep
next_step_agreed fact +0.09 Next step agreed
pain_identified fact +0.05 Pain identified
budget_discussed fact +0.04 Budget discussed
decision_maker_identified fact +0.04 Decision maker identified
timeline_known fact +0.04 Timeline known
engagement score +0.08 Buyer engagement
rapport score +0.04 Rapport
urgency score +0.05 Urgency
objection_open code -0.14 Objection open
prospect_disengaging noul -0.16 Prospect disengaging
buyer_confused noul -0.05 Buyer confused
competitor_mentioned noul -0.04 Competitor mentioned
rep_pitching noul -0.04 Rep pitching features
rep_talking_too_much code -0.10 Rep talking too much
rep_discovery_question noul +0.03 Rep asked discovery question

Sums: positives 0.74 (of which 0.17 are score weights that can also go negative), negatives -0.53. tests/test_engine_unit.py:90-100 asserts positives in [0.5, 1.2], negatives in [-0.8, -0.3], and that every noul/fact/score weight has a matching question. Theoretical range of inst before clamping: 0.25 - 0.53 - 0.17 = -0.45 up to 0.45 + 0.74 = 1.19, so the clamps P_MIN, P_MAX = 0.03, 0.95 (constants.py:320) are reachable; tests/test_engine_unit.py:81-87 tests them.

The kind values mean: noul = raw Jev probability this turn; fact = raw Jev probability until persisted, then hard 1.0 for the rest of the call (engine.py:242-243); code = computed in code (objection_open, rep_talking_too_much); score = centred level.

3b. EMA (ema(), engine.py:51-52; applied at engine.py:335)

p_t = EMA_ALPHA * inst_t + (1 - EMA_ALPHA) * p_{t-1},   EMA_ALPHA = 0.40 (constants.py:319)
p_0 = INITIAL_PROBABILITY = 0.30 (constants.py:321)

The EMA is per utterance, not per second: a fast back-and-forth moves the number faster than a slow one. Half-life is ln(0.5)/ln(0.6) = 1.36 utterances, so a single transient spike (e.g. commitment 1.0) decays to ~36% of its effect two turns later (tests/test_engine_unit.py:240-250 asserts the fade). No clamp is applied to p_t itself; it stays inside [0.03, 0.95] because inst is clamped and p_0 is inside the range.

3c. "What moved the number" (sensitivity(), engine.py:84-93)

Per-signal contribution deltas between the previous step and this one (stage prior counted as a signal labelled "Call stage"); deltas below 0.0005 are dropped; sorted by |delta| desc, then delta desc, then name (deterministic on ties); top SENSITIVITY_TOP_N = 3 (constants.py:336). Called in _apply with the previous step's contributions (engine.py:336,360). Rendered as +/- pts in the UI (index.html:402-405). Note it is attribution of instantaneous contribution changes, not of the EMA'd probability change; the two can disagree in sign on a given turn.

3d. Talk ratio, pace, monologue

3e. Recompute without Jev (set_weights + recompute, engine.py:364-382)

Every Step stores raw answers and the derived features (engine.py:136-152). recompute() re-runs composite + EMA over step.features with the current weights and stage priors and rewrites instant, probability, contributions, sensitivity on each step. Because it reuses step.features (not answers), weights and stage priors are retunable offline, but thresholds are not: fact persistence (0.70), objection open/clear (0.60), speaker masking and the talk-ratio curve are already baked into the stored features. Changing FACT_PERSIST_THRESHOLD would require re-deriving features from answers (the data is there; the code path is not). tests/test_engine_unit.py:253-268 checks zero Jev calls on recompute; tests/test_server.py:48-75 checks the WebSocket round-trip.

3f. Reported outcomes (eval/timelines.json, real jev-1.13.0 run)

call final p min max facts persisted coaching gated latency mean / p95 input tok output tok cost
good (42 utt) 0.905 0.258 0.908 pain, budget, DM, timeline, next step 5/42 345 / 522 ms 330,109 58,646 $0.0139
bad (38 utt) 0.075 0.075 0.304 competitor, budget 4/38 391 / 532 ms 298,774 53,177 $0.0125
mixed (42 utt) 0.805 0.155 0.882 pain, budget, DM, timeline, next step 2/42 382 / 525 ms 330,793 58,698 $0.0139

Per request: ~7.2-8.1k input tokens, ~$0.0003 at $0.042/Mtok input, output free (constants.py:17; /home/stevan/dev/jev/docs/vendor/typesafe/models.md:13,18). First request of a process is slow (876 ms on good, eval/timelines.json row 0) because of connection setup.


4. Fact persistence and objection lifecycle

4a. Facts (_update_facts, engine.py:257-271)

Observed in the integration report: because BASE_CONTEXT already contains a pain description (tests/test_integration_jev.py:28-33), pain_identified reads 0.82-0.99 on nearly every case regardless of the latest utterance (eval/integration_report.json, e.g. :42, :218, :808). Window facts are context-driven, which is the point, but it also means one strong statement 11 turns ago still counts.

4b. Objection state (Objection dataclass engine.py:126-132; _update_objection engine.py:273-297)

State: open, type, confidence, since (utterance index), probability.

On a prospect turn:

  1. If prospect_objecting >= OBJECTION_OPEN_THRESHOLD (0.60):
    • if not already open, open it with since = this index; (if already open, since is not refreshed);
    • probability = prospect_objecting;
    • if objection_type.choice != "none" and objection_type.confidence >= OBJECTION_TYPE_MIN_CONFIDENCE (0.40), set/overwrite type and confidence; else if no type yet, type = "unknown";
    • return (no expiry check this turn).
  2. Else, if open and max(prospect_accepts, buying_signal, commitment, next_step_agreed) >= OBJECTION_CLEAR_THRESHOLD (0.60) and prospect_disengaging < 0.60, clear (fresh Objection()), return. The disengaging guard is the "sure, okay, I have to run is not acceptance" rule (engine.py:292-294, tested at tests/test_engine_unit.py:169-191).

On any turn that reached the end: if open and index - since > OBJECTION_MAX_AGE_UTTERANCES (8), clear (engine.py:296-297).

Two behaviours worth knowing because they differ from the README's wording ("or 8 utterances without re-raising"):

objection_type is asked speculatively every turn and will name a type for non-objections: in the integration report a neutral "What does something like this cost?" gets price (0.68) (eval/integration_report.json:775), and "just send me some info... I need to run" gets timing (0.96) (:435). That is why the type is only consulted when prospect_objecting fires, and why prospect_objecting and not objection_type != none is the gate.

Effect on the number: objection_open feature = 1.0 while open, weight -0.14 (engine.py:244), i.e. -14 pts of instantaneous estimate, -5.6 pts on the EMA in the first turn.


5. The 15-move playbook, gating, and not_for

data/playbook.json (_comment at line 3: "Jev PICKS a move (Choice over the ids); code shows the text. what/not_for become the Choice option descriptions Jev sees."). Loaded once at import (constants.py:35-41); served raw at GET /api/playbook (server.py:75-77) and in the hello message.

# id (playbook.json line) title what not_for phrasings
1 dig_into_the_problem (5) Ask a discovery question Early in the call, or the prospect's situation, current process, and problems are not yet understood. Ask an open question that gets the prospect describing how things work today. Situations where the pain is already clearly described, or where the prospect just raised a concern that needs a response first. "Walk me through how that works for your team today, start to finish." / "What's the most painful part of the current process for you?" / "What made you take this call now, of all times?"
2 quantify_the_pain (16) Quantify the pain The prospect described a problem in words, but its cost in hours, money, lost jobs, or missed revenue has not been pinned down. Ask for numbers so the value is concrete. Calls where no problem has been mentioned yet, or where the cost has already been stated. "Roughly how many hours a week does that eat up across the team?" / "If you had to put a dollar figure on that problem per month, what would it be?" / "How many jobs a month would you say slip because of that?"
3 ask_about_budget (27) Ask about budget The prospect has a real need, but nothing is known about their budget or what they pay today, and no concern is currently open. Find out whether money exists for this. Calls where budget has already been discussed, where the prospect just objected to price, or before any problem has been identified. "What are you spending today on scheduling and dispatch, including the manual work?" / "Is there a budget set aside for fixing this, or would we need to build the case together?" / "What range would make this an easy yes for you?"
4 confirm_decision_process (38) Confirm the decision process The prospect is interested, but it is unclear who makes the purchase decision, who else must approve, or how buying works at their company. Calls where the decision maker and approval process are already known, or where a price or trust concern is open. "Besides you, who else would need to weigh in on a decision like this?" / "How did the last software purchase like this get approved on your side?" / "If we agree this is a fit, what happens next internally?"
5 state_price_with_context (49) State the price, anchored to value The prospect asked what the product costs and has not pushed back on price. Give the number plainly and immediately tie it to the cost of the problem they described; do not dodge or delay. Prospects who already heard the price and objected to it (that is a price objection), or calls where no one has asked about price. "For a team your size it's eleven hundred a month, which is roughly a quarter of what you said the slipped jobs cost you." / "Happy to. It's nine hundred a month on the annual plan. Compare that to the thousand a week in wasted hours we just worked out." / "Straight answer: eight fifty a month for nine techs. Want me to show how that nets out against the double bookings?"
6 handle_price_objection_roi (60) Handle the price objection with ROI The prospect pushed back on price, cost, or value for money. Respond by connecting the price to the cost of the problem they described, not by discounting. Calls where no price or cost concern was raised, or where the concern is about trust, timing, or authority. "Fair. Let's compare it to what the problem costs you now: you mentioned roughly ten hours a week of manual scheduling." / "If it saved even two missed jobs a month, would the math work for you?" / "What would it need to save you per month for the price to feel obvious?"
7 handle_trust_or_risk_concern (71) Reassure with proof The prospect doubts the product will work for a company like theirs, doubts the vendor, or worries about implementation risk or disruption. Offer evidence: a similar customer, references, a pilot, or a guarantee. Price or cost concerns, or calls where no doubt has been voiced. "That's a fair worry. Would it help to talk to a company your size that switched last quarter?" / "We can start with a two-week pilot on one crew, so nothing changes for the rest of the team until you've seen it work." / "What would you need to see to feel confident this is low-risk?"
8 handle_timing_objection (82) Handle the timing objection The prospect says it is not the right time: too busy, mid-season, mid-project, or 'revisit next quarter'. Find out what actually changes by then and connect waiting to the cost they described, without pressuring. Price, trust, or authority concerns, or prospects who have already named a deadline they want to hit. "Totally understand. What would be different in September that would make this easier?" / "If the busy season is the pain, would it be worth having the fix in place before it starts rather than after?" / "Would a lighter pilot on one crew be manageable now, so nothing lands on your plate during the peak?"
9 address_competitor_comparison (93) Address the competitor comparison The prospect mentioned another vendor or their current tool. Ask what they like and dislike about it, then differentiate on what matters to them. Calls where no other vendor, tool, or alternative was mentioned. "What do you like about them, and what's made you keep looking?" / "Where is the current tool letting you down most?" / "The main difference for teams like yours is real-time re-dispatch; how do you handle same-day changes with them?"
10 clarify_simply (104) Clarify in plain words The prospect asked what something means, asked the rep to repeat, or seems lost. Explain plainly in one or two sentences with a concrete example. Calls where the prospect clearly follows the conversation. "Let me put that more simply: when a job runs late, the schedule updates itself and the customer gets a text." / "Good question, I skipped a step. In plain terms it means..." / "Can I show you with an example from your own day?"
11 stop_talking_ask_open_question (115) Stop talking, ask an open question The rep has been talking at length or listing features, and the prospect has gone quiet or is giving short answers. Hand the floor back with an open question. Moments where the prospect is actively talking or has just asked a direct question. "I've been talking a lot. What's your reaction so far?" / "How does that compare to what you were hoping for?" / "What questions does that raise for you?"
12 summarize_and_check_understanding (126) Summarize and check understanding The prospect shared several details, priorities, or concerns. Reflect back what you heard in a sentence or two and confirm you got it right. Calls where the prospect has said very little so far. "So if I've got this right: the dispatcher is drowning, techs get sent to the wrong jobs, and customers are calling to complain. Did I miss anything?" / "Let me make sure I understand what matters most to you." / "It sounds like the real issue is same-day changes, not the weekly schedule. Is that fair?"
13 create_urgency_honestly (137) Create urgency honestly The prospect is interested but has no timeline or says 'someday'. Tie the decision to the real cost of waiting they have already described. Prospects who already have a deadline, or who just raised a price or trust concern. "Every month this stays as is costs you the ten hours a week you mentioned. What would it take to decide before the busy season?" / "Is there a reason to wait, or is it more that it hasn't been a priority?" / "If we started this month, you'd be live before summer peak. Does that timing matter to you?"
14 propose_next_step (148) Propose a concrete next step The conversation is positive, the prospect is engaged, and no concrete next action (demo, trial, follow-up meeting with a date, proposal) has been proposed or agreed yet. Calls where a next step is already agreed, or where an objection is still open. "How about I set up a 30-minute demo with your dispatcher on Thursday, using your actual job list?" / "Can I send a one-page proposal tonight and we review it together on Friday?" / "Let's get your ops lead on a call next week; would Tuesday or Wednesday work?"
15 ask_for_the_close (159) Ask for the close Need, budget, decision maker, and timeline are known, concerns have been handled, and the prospect is showing buying signals. Ask directly for the commitment. Calls where key qualification facts are missing or a concern is still open. "It sounds like this solves the problem. Shall I send the agreement over today so you can start Monday?" / "Is there anything stopping us from moving forward?" / "Would you like to go ahead with the two-crew plan we discussed?"

5a. Gating (_coaching, engine.py:299-320)