M1b: live granularity and the adaptive plan
Status: engineering proposal, 2026-09-27, after Stevan's replay review (docs/design/FEEDBACK-2026-09-27.md). Feeds an otto-plan PRD. Everything here comes from the four investigations run that day on the live D1 data, the code at 9cb85fc and the reference copilot, or from the files cited. Where two investigations disagreed, §9 says which figure was kept and why.
A note on the name: the M1 PRD used "M1b" for the secondary M1 deliverables (uploads, the L1 target, L5), all landed in the M1 run. This proposal takes the name for the next milestone. If that confuses the PRD tooling, call it M1.5.
Glossary, once: an utterance is one line of the transcript; a fragment is one raw piece from Aircall; a decision point is a line after which Jev is asked; a noul is a yes/no question Jev scores 0 to 1; a situation is a named pattern in the client's words with approved lines under it; a slot is one client fact (residency, amount); the presenter is the page code that decides when the rep sees what the engine decided; dwell is the minimum time something stays on screen; the Durable Object (DO) is the per-call server process; a subrequest is one outbound call the Worker makes.
Line numbers are the current transcript's (transcript_rev 1) throughout; COPILOT-104b renumbers them and carries the citations over. §10 lists what two independent reviews changed on 2026-09-27.
1. Summary
- The transcript and Jev both run once per stitched turn, and this call's turns run to 44.5 s and 112 words. That, not Jev's speed (p50 304 ms), is why nothing felt live. Two changes fix replay: the transcript reveals words as they are spoken (browser only, no cost), and long turns are split at sentence ends under a time rule (96 to 133 decision points, longest wait 45.6 s to 13.5 s on evenly spread word times, $0.034 to about $0.047 per call before the pre-judge questions).
- Feedback (ticks, chips, cautions, a new point on the open card) moves at sentence ends. The card itself switches at the end of the client's run of lines, behind one flag so Stevan can flip it.
- True partial-then-final decisions are not needed: sentence-sized units give the cadence, and in live audio (M3) the interim words are display-only while the finished sentences are the units, so the same model carries over.
- Suggestions vanished for two reasons: the situation gate is re-judged from scratch every client turn and the 20 s dwell protects only the card's title; and a point born on a rep turn can be the thing the rep just said. Four presenter rules (a 12 s point floor, stacking to 3, a sticky last suggestion, a grace on rep turns until the next rep decision) and two engine rules (survive one uncertain miss, pre-judge the next item's fit and its situation choice) fix both without a database change.
- The plan needs kinds. A fact ticks silently from any source and has no row; a must-say is listed while it applies and surfaces when due; a judgement item exists only while its trigger fits. On this call the live list drops from 10 rows to about 3 and the after-call score reads 3 of 4 applicable must-says instead of 2 of 7.
- Pre-call facts seed the engine at evidence i=-1 through a closed-vocabulary
precall_json, snapshotted per session, each with its source: the sign-up wizard viapd_ct_idis M2 (one sample call has the key); on 3339895706 the values are typed in and labelled "Seeded for the demo". "Check we can serve them" and "Understand the transfer" are then satisfied before the first line. - The double tick is one concern cleared by a 3-word fragment and re-raised 1.7 s later as a new row. Fix: never clear on a client line under 4 words, and reopen the same row within 60 s of call time. On this call's recorded answers that leaves one row, raised twice, done at 06:04.
- Twenty one-commit stories (COPILOT-102 to COPILOT-118, with 104 and 111 split into a/b and 110 into a/b/c, plus one conditional), 33 otto iterations. Stevan's session and the curation are exported before any re-import (104a). The proposed wording stays outside
policy/srcuntil Stevan approves it (110c applies the reply); 107, 114 and 117 render their new lines once their labels are approved and work without them meanwhile. None needs a new Cloudflare service. - Cost: the strong calls land at $0.047 and $0.048 after the split and at about $0.050-0.053 with the pre-judge questions, against the $0.05 gate in
scripts/lib/eval-l6.ts:25. Stevan re-sets it to $0.06 or keeps it, in which case the pre-judge questions are the first drop under budget (decision 16). Live transcription (M3) is on top: $0.090 for this 587 s call at nova-3's $0.552 an hour. - Live audio stays M3. nova-3 streaming is the only Cloudflare-hosted option with interim words and a speaker per word; it needs the paid Workers plan (the free allowance is about 12 minutes a day) and an unverified Aircall audio transport; a one-day transport spike (media stream or separate legs, against Aircall's real-time transcription webhook) belongs before M3 planning, not in M1b.
2. What was measured
All on call 3339895706 (onboarding, 587 s) unless noted; Stevan's session 1d160d96 equals run 49e3e48f decision for decision (every one a cache hit).
| Measure | Value | Source |
|---|---|---|
| Fragments → utterances → decision points | 159 → 122 (96 turns, 26 backchannels) → 96 | D1 utterances |
| Decision points by speaker | client 48 / rep 48 (CS-strong 66 / 65) | D1 utterances |
| Derived rows a forced re-import deletes on 3339895706 | 3,472 decisions in 52 sessions (Stevan's 1d160d96: 86, run 49e3e48f: 96), 391 moments, 3 marks; 1 label on 3303259297 |
D1; src/routes/calls-import.ts:24 |
| Utterance length: median / p90 / max | 1.0 s / 14.4 s / 44.5 s | forensics |
| Utterances of 15 s or more | 11, holding 309.6 of 619.2 s of speech, 45% of words | forensics |
| Word-weighted wait from spoken to visible | 9.1 s (CS call 3303259297: 6.0 s) | forensics |
| Gap between decision points: median / p90 / max | 1.8 s / 16.0 s / 45.6 s | reference report |
| Topics in the five longest client turns | 19 (5, 4, 4, 3, 3), one decision each | forensics |
| Jev provider latency, 5,655 live full requests | p50 304 ms, p95 413 ms, p99 798 ms; flat against size (319 ms at 6k tokens, 346-365 ms at 13-15k) | D1 jev_requests |
| End-to-end p95 at 1x, 8 eval runs | 448 to 3,506 ms; latest run 2,042 ms | eval reports |
| Input tokens per full request | median 7,797, mean 8,489, p95 9,367, max 10,519 (CS-strong mean 6,993, max 9,323) | D1 jev_requests |
| State share of a request | mean 1,838 characters (about 525 tokens) of a 31,448-character request | reference report |
| Decisions that fell back to window 8 (soft budget 11k) | 365 of 3,472 on this call across runs | reference report |
Cost per call today (eval cost_by_call) |
$0.034 onboarding-strong, $0.038 CS-strong, $0.017 onboarding-weak, $0.007 CS-weak | eval/reports/recc-20260927.l4l6.json |
| Decisions with the engine's move card shown / with an open plan point | 50 of 96 / 12 of 96 (3 distinct cards) | forensics |
| CS call: decisions with an open point, 3 runs | 0 of 131 | forensics |
| Card spans under 6 s | 4 of 17 | presenter simulation |
| Amount point on screen tinted | 1.0 s (needed 6.0 s at 2.5 words/s) | presenter simulation |
| First-transfer-date point on screen | 23 s, then withdrawn uncovered | simulation, code path |
| Card chosen at 463.8 s, shown at 513.2 s | 49.4 s wait (dwell + a 41 s rep monologue) | simulation |
| Presenter delay on the other 10 of 12 card switches | ≤ 0.1 s | simulation |
| Reference copilot: decisions per minute / card shown | about 5 / on 34-40 of 38-42 lines | reference report |
3. Live granularity (a)
3.1 The unit model
Today: fragment → stitched turn (merge same-speaker fragments under a 1.0 s gap, 2.5 s when unfinished, up to 120 words; split only above 120 words, src/ingest/stitch.ts:59, 161-171, 193-213) → one decision per turn. The browser reveals a line when the clock passes its t_end (web/src/transcript.ts:209-212) and sends utterance{i} at the same moment (web/src/clock.ts:429, web/src/player.ts:108-113).
Proposed, the same model in replay and live:
| Unit | What it is | Who produces it | What moves on it |
|---|---|---|---|
| word | a word with an estimated (replay) or real (live) time | browser (replay), STT interim result (live) | transcript text |
| part | a sentence-sized piece of a turn, at least 6 words and 5 s, forced at 12 s | stitch() v2 (replay), STT finals through the incremental stitcher (live) |
one decision: ticks, chips, caution, points on the open card |
| turn | a same-speaker run of parts | stitch() |
nothing extra |
| run end | the last client turn before the rep speaks | derived in the presenter from the utterance list (replay); in live, known when the other speaker's first fragment arrives or the STT endpoint fires, about a second later | the card may switch; items change state |
The reference copilot used one unit, the finished utterance, at about 5 a minute, and its live-mic mode showed interim words without sending them to Jev (static/index.html:454-462). We already decide at 9.9 a minute on this call. The gain is bounding the worst case, not deciding more on average.
3.2 Replay now
T1. Transcript words as they are spoken (browser only). The row for line i is inserted when the clock passes u.t, and its text grows by floor(words × (t − u.t) / (u.t_end − u.t)) words. Aircall gives no word times (the sample's keys are i, start, end, role, text), so words are spread evenly inside each fragment, and only the fragment boundaries are real; this call runs at 3.0 words a second. Inside a long fragment (up to 41 s here) a pause shifts the words after it by the pause's length, so the reveal is an animation of an estimate, not a measurement, and the gap figures below inherit the same estimate; they are re-measured on real word times once nova-3's words are kept (open question 11). Zero Jev cost. Honest: nothing appears before its estimated spoken moment, unlike the reference's "show the line at its start and cap silences at 6 s" (server.py:27, 196-204), which we reject because it shows text before it was said and cannot carry to live.
T2. STITCH_V2, the split rule. Inside a merged turn, cut a part at a sentence end once the part has at least 6 words and its estimated span is at least 5 s; force a cut at the first sentence or clause boundary after 12 s. Each part's t is the previous part's t_end (today every part keeps turn.t, stitch.ts:205). Parts are ordinary kind:'turn' utterances, so the protocol, the DO, the engine and the decision table need no change. contentHash includes stitch_version, so a re-import is a new transcript_rev and a one-off paid re-run.
Measured on the real D1 lines (word times spread evenly):
| Call | Decision points today → v2 | Per minute | p90 gap | Longest gap | Cost today → v2 |
|---|---|---|---|---|---|
| 3339895706 onboarding-strong | 96 → 133 | 9.9 → 13.7 | 16.0 → 10.4 s | 45.6 → 13.5 s | $0.034 → $0.047 |
| 3303259297 CS-strong | 131 → 165 | 12.1 → 15.2 | 12.4 → 8.7 s | 56.3 → 13.4 s | $0.038 → $0.048 |
| 3485591407 onboarding-weak | 50 → 68 | 8.0 → 10.8 | 15.4 → 10.5 s | 31.8 → 13.2 s | $0.017 → $0.023 |
| 3347356034 CS-weak | 22 → 44 | 4.1 → 8.2 | 31.2 → 11.1 s | 81.4 → 75.0 s (a silence) | $0.007 → $0.013 |
Cost is the eval run's measured cost_by_call scaled by the decision count (mean 8,489 input tokens per request on the onboarding call, 6,993 on CS-strong, at $0.042 per million). The decision-count ratio runs from 1.26x (CS-strong) to 2.0x (CS-weak), 1.39x on the strong onboarding call, so the thresholds in COPILOT-106 are re-tuned by the eval rather than by one multiplier. The alternative rule, split every 25 words, gives 152 points on the strong call and $0.050; every sentence gives 27.8 a minute. The time rule is kept because it bounds the worst case with the fewest extra decisions (§9).
Partial versus final. A true partial (partial{i, upto_words} stepping a copy of the state, superseded by the final) was costed at 4-6 days: a new message, a part column or unpersisted decisions (which breaks "every decision is replayable", INV-COPILOT-001), and presenter rules for supersession. A light partial request would be 2-3k tokens (about $0.0001), but the sentence split already gives sentence-level cadence, and in live audio the interim words are display-only while the final segments are the units. Partials are therefore not proposed for M1b or M3.
Window and budget. The window is 12 utterances including backchannels (src/engine/state.ts:63-84), which on this call covers a median of 58 s and 180 words; the reference's 12 covered 161 s and 537 words. Sentence parts shrink it further. The state is only about 525 of 8,489 tokens, so widening is cheap in tokens: M1b raises the window to 16 units and keeps the soft 11k / hard 12k budget. Defining the window by time or words (about 120 s or 400 words) and raising the hard cap to about 16k is a policy decision for Stevan: Cloudflare served 1,599 eval requests up to 15,674 tokens without error and latency is flat, but the 12k cap is our own (architecture D4). The pre-judge questions of §4 (up to three fits:: at 246-292 tokens each and one situation:: Choice at 361-397, like-for-like §3.4) add about 1,200 tokens to a client-part request, about 650 averaged over all decisions, and get a drop step of their own, prejudge, ahead of fact_values (today's first drop, budget.ts:117-123).
Latency and subrequests. Per decision nothing changes: provider p95 413 ms, end-to-end p95 about 2 s at 1x on the latest run. Parts inside a turn are 5-12 s apart, so the serial chain does not queue at 1x. The DO still spends 2 subrequests per decision; at 20x the 50-per-invocation guard (src/session/subrequests.ts:30-34) reconnects after about 23 decisions, so a 133-decision replay needs about 5 reconnect{after_i} round trips (133 ÷ 23) instead of 4; COPILOT-104b quotes the measured count. The COPILOT-058b path handles it. The rep's wait is the part boundary plus arrival plus the presenter, so COPILOT-118 measures spoken-to-visible and sentence-end-to-first-feedback end to end at 1x; the 13.5 s bound above is the boundary alone.
Thresholds counted in decisions. With about 1.4x more decisions: concern_max_age 8 → 12, purpose_max_decisions 8 → 12, card_cooldown_utts 3 → 4, COVERED_DWELL 4 → 6, window 12 → 16; confirm_updates 2, stage_confirm 2 and ema_alpha 0.4 are kept on purpose (two confirmations at a faster cadence is what a sentence-level card should need) and checked by the same run; the new reopen window (§6) is in call seconds, not decisions. Re-tuned by one eval run (L1 pass rate at or above the 2026-09-27 baseline, L4 and L6 pass, window_to_8 fallbacks at or below today's 10.5%).
3.3 The cadence rule
| Element | Moves at | Rule |
|---|---|---|
| Transcript text | word time | T1 |
| Fact chips, point ticks, caution, purpose | arrival of any decision (sentence end) | unchanged (presenter.ts:17-18) |
| A new point on the open card | arrival of a client-part decision | new; today it waits for a client turn end |
| A must-say that becomes due on a client signal (safety asked, ready to book) | the end of that client run, no dwell | treated as an inserted item, like a concern (presenter.ts:164-170; COPILOT-111b marks it) |
| The card switching, item states | the end of the client's run, then the 20 s dwell, then not during a rep turn | LIVE_CARD_AT_SENTENCE_END = false; Stevan's decision 1b flips it |
3.4 Live audio later (M3)
- nova-3 streaming on Workers AI (
interim_results=true&endpointing=300&utterance_end_ms=1000&diarize=true) is the only Cloudflare-hosted option with interim results and a speaker label. Price $0.552 an hour: $0.090 for this 587 s call, on top of the engine's about $0.05, so a live call is about $0.14 all in and the budget is per audio minute (about $0.014) once measured; 836.36 neurons a minute against 10,000 free a day is about 12 minutes if nothing else uses the allowance, so live needs Workers Paid. Interim cadence and final latency through Cloudflare are unmeasured; a 5-minute probe streamingcall-3339895706.mp3at 1x settles that line of the budget. - Mapping: interim results replace the text of the open row (no decision);
is_finalsegments feed the incremental stitcher and are cut into parts under the v2 rule (a final is not a part by itself: Deepgram finalises on pauses, and several finals can make one sentence); the endpoint is the earliest run-end signal, confirmed when the other speaker's first fragment arrives. The saved nova-3 file for this call has 111 utterances, median 3.5 s, max 20.8 s, so its units are near sentence-sized but not bounded at 12 s; the live segmenter is measured on its own, on the probe, before M3 is planned. - Protocol: today
utterance{i}indexes a pre-stored table; live needs an append message carrying the STT's segment id and start time, with the DO assigningiin start-time order and dropping duplicates, and incremental stitching (a lone "Yeah" is provisional until the next fragment,stitch.ts:236-248). On a reconnect the client resyncs to the current state rather than replaying a backlog of stale suggestions, and the live view says "catching up" meanwhile. - Transport: run STT from the browser (short-lived token) and send finals to the DO, so the DO keeps 2 subrequests per decision. Whether Aircall exposes a live media stream, or separate legs per party, is unverified, and one review named Aircall's real-time transcription webhook (utterances as they are recognised) as an alternative we have not checked; a one-day spike against a real Aircall call settles the transport before M3 is planned. The devbox whisper path cannot do live diarised STT (offline clustering, real-time factor 0.27-0.30).
- Realistic live feel: transcript words about 1 s behind speech; a tick or chip 1-2 s after each sentence ends (p95 about 3 s); card changes still bound by the run-end and dwell rules.
- Before M3, uploads can already get real sentence times: keep nova-3's
wordsinfromNova3(src/ingest/normalize.ts:79-92) and split at punctuated word ends instead of interpolating (4-6 h, not in the M1b story list).
4. Card lifetime rules (b)
Why points vanish today, with the file and line:
| Cause | Where | Seen on the call |
|---|---|---|
| E1 The gate is re-judged from scratch on every client turn; below fit 0.6 the card shows "Listening" | src/engine/situation.ts:152-157, 184-194, 232, 243 |
line 17: fits fell to 0.05 and 0.11, the date point left at 133.7 s |
| E2 A switch to another situation of the same move replaces the points | plan.ts via the fit |
line 13: amount → first-transfer date |
| E3 A new current item starts with empty fits, so one decision of "Listening" | situation.ts:210-212 |
line 8 → 9: the amount point arrived after the rep had asked it |
| E5 A point with a slot requirement stays hidden until the slot is known | plan.ts:593 |
works-abroad fitted at 0.67 and 0.69 but {country} was unknown |
| P1 The dwell protects the card's id, not its points | web/src/presenter.ts:164-170, 176-193 |
the two withdrawals above |
| P2 A plan decided on a rep turn is applied at the next change moment | presenter.ts:153-157 |
the stale amount point at 100.3 s |
Proposed rules, all inside G's one-card shape (one card, one tag, points only on the current card, no new colour):
| Rule | Constant | What the rep sees |
|---|---|---|
| R1 Point floor | LIVE_POINT_MIN = 12 s of call time |
a revealed point keeps its slot, tinted or grey, for at least 12 s when a card switch or one uncertain miss (R5) drops it; then it fades. A point the rep covers folds as today, and one whose situation the engine drops on a clear miss (fit under 0.3) leaves within a second, floor or not |
| R2 Stacking | LIVE_POINTS_MAX = 3 |
a newer situation's point is added under the older one; the oldest un-floored point leaves first, and when a fourth arrives the oldest leaves at once, floor or not |
| R3 Sticky last suggestion | LIVE_LAST_SUGGESTION = 12 s |
after a card switch, the previous card's newest uncovered point stays as one grey "Last suggestion: ..." line under the new card |
| R4 Rep-turn grace | until the next rep-turn decision, at most LIVE_REP_POINT_GRACE_S = 3 s |
a point first introduced by a decision on a rep turn is held until the next rep-turn decision arrives (only rep turns carry covered::, plan.ts:302-305); if it arrives covered it renders grey, never tinted. Line 9's point: the next decision is the 2-word client line 10, which carries no tick; the tick comes with line 11 at 100.9 s, 1.7 s after line 9's, so a one-decision hold would still have flashed it |
| R5 Gate hysteresis (engine) | one uncertain miss | a shown situation survives one answer in [0.3, 0.6) or one dropped question and leaves on the second; it leaves at once on an answer under 0.3 (a clear "not this", like the delivery-date must-not case in like-for-like §4.1) or when a different candidate fits at or above 0.6. This amends like-for-like §3.3 (decision 17) |
| R6 Pre-judged fits (engine) | up to 3 fits:: and one situation:: Choice per request |
the item about to become current has its fits, and its situation Choice when it has two or more candidates, asked before it opens (shownSituationId needs the Choice as well as a fit, situation.ts:228-246; ask_about_the_transfer has two situations), so a new card opens with points, not "Listening" |
R1 to R4 are presenter-only: pure functions of the clock, deterministic under replay, no D1 change. R5 and R6 change the state and the question set, so they are a paid re-run and need the eval gate. DESIGN.md §Cadence rules gains rules 10-12 and the constants table gains the four values. The "two or three things worth addressing in one turn" is R2 inside G's card, not a second card. Three 15-word points are about 18 s of reading at 2.5 words a second, so the shotgun board before COPILOT-107's screenshots decides between a cap of three and two (§8).
5. Plan semantics (c)
5.1 Item kinds
| Kind | Satisfied or triggered by | Row in the live list | Counted |
|---|---|---|---|
fact |
its slots known from sign-up, the CRM or either speaker (satisfied_by) |
none once known (it becomes a chip with its source); a card only when surface_when fires: the value is outside the served list, sign-up and the call disagree, or unknown after the discovery stage |
never |
must_say |
the rep's lock (done_when: must_say), any wording |
listed while applies holds; the card when due fires (a signal such as client_ready_to_book or client_asked_about_safety, or a slot test such as an amount leg below the minimum) |
after the call: "3 of 4 that applied" |
judgement |
its situation or trigger fitting | the card while it fits (with the §4 floor); no row before or after | never |
Data sketch (policy, plan-<scenario>.json, under weights_hash, so no Jev question changes):
{"id":"check_serviceability_first","kind":"fact","satisfied_by":{"slots":["residency"],"rule":"served"},"surface_when":{"any":[{"slot_outside_served":"residency"},{"unknown_after_stage":"discovery"}]}}
{"id":"explain_booking_is_binding","kind":"must_say","applies":"always","due":{"signal":"client_ready_to_book"},"done_when":[{"must_say":"said_booking_is_binding"}]}
{"id":"explain_who_holds_the_money","kind":"must_say","applies":"always","due":{"any":[{"signal":"client_asked_about_safety"},{"signal":"client_ready_to_book"}]},"done_when":[{"must_say":"said_who_holds_funds"}]}
{"id":"confirm_funding_account","kind":"must_say","applies":{"slot_in":{"funding_account":["third_party","company_account"]},"or_unknown":true},"due":{"signal":"client_ready_to_book"},"done_when":[{"must_say":"said_fund_from_own_account"}]}
{"id":"explain_settlement_and_cutoff","kind":"judgement","surface_when":{"any":[{"slot_in":{"timing":["this_week","this_month"]}},{"signal":"client_asked_about_timing"}]}}
Every signal, slot and must-say id named above exists in the policy today (client_asked_about_safety, client_asked_about_timing and client_ready_to_book in rules-onboarding.json; funding_account and timing in plan-onboarding.json; the said_* ids in the must-say group), so the grammar changes no Jev question and no hash but weights_hash. What does not exist yet: a bank_country slot (a new fact question, so a bank change with its own L1 case and a paid re-ask, COPILOT-110b), and a served-country list (served_countries in the scenario bank, empty until Stevan or the partners supply one; with an empty list a known residency satisfies the fact and nothing is "outside served"). A kind is single-valued: confirm_funding_account is a must-say whose applies reads the funding-account fact, not a fact that turns into a must-say. agree_next_step stays a fact locked by the client's words only (next_step_agreed persists on client turns, rules-onboarding.json:308, COPILOT-024).
The must-say checklist in rules-onboarding.json:20-104 is derived from the must_say items at build time, so the two lists cannot drift. The kinds for the ten first-call items, and the proposed wording, are in FEEDBACK-2026-09-27.md §2d; nothing ships until Stevan approves the labels (the loader hard-rejects drafts, INV-COPILOT-003).
5.2 Pre-call facts
- Source. The sign-up wizard records purpose, expected first trade, expected volume, currencies, countries, source of funds, current provider and frequency (
docs/research/ct-domain-brief.md§1.2). The call row storespd_ct_id(src/ingest/import-payload.ts:40,src/routes/calls-import.ts:57, 149) and nothing reads it. This sample call'spd_ct_idis NULL, so the demo seeds by hand the values the call itself establishes, with sourcedemoand the chip suffix "Seeded for the demo", never "From sign-up" (a replay must not claim evidence it did not have). 3485591407 carriespd_ct_id109829, so the real join has a sample when M2 builds it (the replica path is in the internal runbookdocs/runbook/ct-replica.md). The wizard records source and destination countries, not residency (ct-domain-brief.md§1.2, step 2); the join takes residency from the KYC address (step 4). - Shape.
calls.precall_json, validated against the policy's closed vocabularies only: every key is a fact slot of the policy that has a closed list (client_type,residency,transfer_purpose,frequency,documents_status,current_provider,funding_account,timing, andbank_countryonce 110b adds it), its value that slot's label id (salary_or_pension, notsalary;proof_of_address_outstanding, notoutstanding);currency_pairas two ISO 4217 codes from the bank's list;amountas digits plus a code;source ∈ {signup, crm, demo}. The type has no free-text field, so nothing personal can enter D1, the DO, Jev or the browser (INV-COPILOT-004); a chip stays an approved label (INV-COPILOT-003). - Snapshot. Copied into
sessions.precall_jsonat session start, so a replay, a seek and a recompute see the same facts (INV-COPILOT-001). - Engine.
initialState(p, precall?)seedsfact_values[slot] = {value, conf: 1, i: -1, t: 0, source},client_typeand theknown_factlocks; the four callers (src/session/handlers/load.ts:131,seek.ts:55,checkpoint.ts:254,recompute.ts:73) pass the snapshot. Existingdone_when: slotandwhen_slotrules then work unchanged;{country}already renders the display name "Saudi Arabia" from the id (web/src/render.ts:87), and params stay ids (like-for-like §3.8). The state hash changes, so the first replay is paid.conf: 1is a starting point, not ground truth: a confident different value on the call replaces it (the chip reads "updated") and a disagreement surfaces the fact's card. - View. Evidence i=-1 renders the source's label (
plan.known.signup,plan.known.demo) and no time link; the jurisdiction concern is code-gated off while residency is known and on the served list, which is empty until supplied (§6). - Prior. A sell currency in {SAR, AED} with residency in that country ranks the salary-home situations first; it never writes a slot or a chip. The transfer-purpose trigger gains
paid,payday,pay home,earn,income.
5.3 Silent satisfaction, never-applicable items, the review view
- A satisfied fact leaves the list into
CallPlan.known[]with its source; a must-say that does not apply is not sent to the live view at all (the "Not needed on this call" group moves to the review view); the tally leaves the live head. - The review view scores completeness as coaching: must-says "3 of 4 that applied", each with why it applied, when it became due, when it was said or the miss with the suggested line and the moment link (here: 08:16 "is it gonna be safe", then line 109); facts "known at the end: N of M, 6 from sign-up, 4 from the call"; no score for judgement items (they are never counted, §5.1); and "covered before the guide suggested it" (here 2 of 2: documents at line 46, booking at line 89). A must-say that becomes due in the last rep turn is not counted.
- Out of scope for M1b, flagged: one
amountand onecurrency_pairslot cannot hold the Canada leg (about 5,000 CAD a month, under the GBP 5,000 minimum at the policy's 0.58). Multi-leg slots are an M2 policy change (§8). M1b stopgap (COPILOT-111b): onceamountorcurrency_pairhas been updated during the call,minimum_transferis never markednot_needed(it stayslater), so a second leg cannot be waved through silently, and the review view says "a second amount was heard". A correction ("fifty thousand, actually fifteen") looks the same to one slot and is also M2.
6. The duplicate tick (d)
Trace: concern:jurisdiction:54 raised 05:08 (line 54, "worried ... being a tax resident"), closed 05:26 when the 3-word fragment "Sure. Well, I" (line 56) scored as client_accepts at or above 0.6 (src/engine/concern.ts:120); concern:jurisdiction:58 raised 05:27 on "both. I'm just more hesitant" as a new episode (concern.ts:110 sets start_i = u.i when the previous one is closed), inserted as a new row by plan.ts:445-459 (existing matches only the same start_i), closed 06:04. Both carry concern.jurisdiction.headline (policy/src/playbook-onboarding.json:2056). Not a re-template (the purpose locked at line 1 and template ids are shared, plan.ts:259-274), and not a client ask (those dedupe by move, plan.ts:495).
Fix, in order of leverage:
- Reopen instead of re-insert. A raise of the same type within
concern_reopen_window_s(60 s of call time) of the last close keeps the previousstart_i; the done row returns to current withdone_at: nulland reads "raised again 05:27". In seconds, not decisions: 6 decisions are about 36 s today and 26 s at 13.7 a minute. - Clearing guard. A concern clears only on a client utterance of at least 4 words; the Jev signal (
client_accepts≥concern_clear) is still required, the guard only says which lines may carry it. No mid-sentence or run-end test: the engine cannot know a turn ends its run until the next one arrives (lastOfRunexists only insidestitch(),stitch.ts:190, and is not stored). STITCH_V2 does not merge across speakers, so a fragment like line 56 still stands alone; the word count handles it. Measured on the recorded answers of this call: line 56 scoredclient_accepts0.71-0.77 in every run and no longer clears; line 58 (client_objecting0.75-0.81) then re-raises the open concern (raises2, samestart_i54,concern.ts:102-110), so there is no reopen on this call; line 64 "It is. It's very" (4 words,client_accepts0.70-0.75) clears it at 364.3 s, as today. One row, raised twice, done at 06:04. The reopen path is tested on a synthetic transcript. - Gate. The jurisdiction concern cannot open while residency is known and on the served list (§5.2). The policy has no served list today (
grep -ri served policy/srcfinds nothing), so it starts empty and the gate is inert until Stevan or the partners supply one; keyed on "known" alone it would silence the concern for every recognised country and lose the "we can't serve you" exit (FEEDBACK 2c). With SA on the list, no episode opens on this call; without it, one row (fixes 1-2). - A situation for the real worry. The policy has no tax-residency or privacy concern type (
policy/src/onboarding.json:246-252lists the ten), so the classifier chose jurisdiction. A document situation "Will this address tie me to that country?" is drafted for the red pen (FEEDBACK-2026-09-27.md§2b), with its line marked [VERIFY], so it stays out ofpolicy/srcuntil verified. A concern type of its own (tax residency, privacy) would be a bank change (a new classifier label); M1b drafts the situation, M2 decides the type from the corpus.
Recompute is approximate here because concern_open feeds the state Jev saw (architecture §5), so the fix is verified by a replay, not a recompute.
7. PRD sketch (e)
Format as in prd.md §5. Every story is one commit. An otto iteration is one otto pass over one story (one context window, one commit); the estimate is 2 where a live probe or an eval run is likely to need a second pass. Executor claude:opus throughout. Proposed wording never enters policy/src before Stevan's reply: the loader rejects any unapproved text (src/policy/loader.ts:4-9, INV-COPILOT-003), so a draft under an approved id would stop every build, v4 included. Proposals live in policy/proposals/2026-09-27.json and on policy/LABELS.md; COPILOT-110c applies the reply (decision 2f). A story marked [LABEL PENDING] renders its new line only once that label is approved and works without it meanwhile.
Phase A: live granularity in replay
ID: COPILOT-102
Title: Transcript text appears word by word between a line's start and end
Description: As a rep watching a replay, I want each line's words to appear as they are spoken rather than when the line ends, so that a 45 s client turn reads as it happens and nothing appears before its estimated spoken moment.
Acceptance Criteria:
web/src/transcript.tsinserts the row for lineiwhen the clock passesu.t(todayu.t_end,transcript.ts:209-212) and sets its.textto the firstkwords,k = floor(words × clamp((t − u.t) / (u.t_end − u.t), 0, 1)), with a line whose span is 0 shown whole; the row carriesdata-partialuntilt ≥ u.t_end; the latest-line tint sits on the row being spoken; rows stay iniorder: a row inserted later with a loweri(a backchannel whosetfalls inside a partial line, lines 6 and 7 inside line 8) goes above the partial row; a seek back removes rows withu.t > tand truncates partial ones; covered tags attach only to complete rows. The "no future lines" rule stands: nothing beyond the clock is drawn.docs/design/DESIGN.md§Transcript pane gains: "Text appears word by word between the line's start and end; inside a line the times are estimated by spreading the words evenly over each fragment until live transcription supplies real ones (M3)."tests/unit/web-transcript.test.tsontests/fixtures/stitched/3339895706.v1.json: at clock 44.9 s row 8 is absent; at 60.0 s it shows 38 ± 3 of 112 words anddata-partial; at 89.5 s it is complete; row 7 (t 69.3) sits above row 8 at 70.0 s; a seek from 89.5 to 50.0 truncates row 8.npx vitest run tests/unit/web-transcript.test.ts→ all passed;npm run typecheckexits 0.- [UI] Deploy (
node scripts/deploy.mjs) and capture both themes at 1440x900 of the deployed dashboard replaying 3339895706 at 1x, clock 01:00, showing line 8 partly revealed:docs/design/evidence/COPILOT-102-{light,dark}.png.
Dependencies: none
Priority: HIGH
Executor: claude:opus
Estimate: 1 otto iteration
ID: COPILOT-103
Title:
STITCH_V2: sentence-and-time split of long turns, with true part start timesDescription: As the engine, I want long turns split into sentence-sized parts under a time rule so that Jev is asked every 5-12 s during a monologue, while v1 output stays byte-identical.
Acceptance Criteria:
src/ingest/stitch.ts:StitchParamsgains optionalsplitMinWords,splitMinS,splitForceS;STITCH_V2 = Object.freeze({ ...STITCH_V1, version: 2, splitMinWords: 6, splitMinS: 5, splitForceS: 12 }). Inside a merged turn, after the merge step (stitch.ts:161-171) and before the parts loop (:193), the text is cut at a sentence end once the part has at leastsplitMinWordswords and its estimated span is at leastsplitMinS; a cut is forced at the first sentence or clause boundary (. ? ! ;or,) aftersplitForceS, else at the next word boundary. Spans are estimated from the turn's member fragments (real boundaries) and by word share inside a fragment (as:205does today; §3.2 T1 states the limits of that estimate). Each part'stis the previous part'st_end; the first part keepsturn.t.decision_point,kind,member_idsare computed as today. No merging across speakers: a 3-word client fragment between two rep lines stays a line of its own.- Golden test:
stitch(fragments, STITCH_V1)on the four samples' fragments is deep-equal totests/fixtures/stitched/<call>.v1.json(unchanged). - On 3339895706 under
STITCH_V2: between 125 and 145 utterances withdecision_point = 1; the longest gap between consecutive decision points ≤ 15 s; the 90th percentile ≤ 11 s; no part under 4 words unless it is the last part of its turn;tstrictly non-decreasing and every part'st_end ≥ t. The test prints the three numbers. node scripts/make-fixtures.mjswritestests/fixtures/stitched/<call>.v2.jsonfor the four samples beside the v1 files.npx vitest run tests/unit/stitch.test.ts tests/unit/fixtures-clean.test.ts→ all passed;npm run typecheckexits 0.
Dependencies: none
Priority: HIGH
Executor: claude:opus
Estimate: 2 otto iterations
ID: COPILOT-104a
Title: Export the review sessions, the curation and the
i-keyed artefacts before any re-importDescription: As Stevan, I want my replay session, the run it matched and every mark and label saved as fixtures before a transcript is replaced, so that a forced re-import, which deletes every derived row of the call (
src/routes/calls-import.ts:24, 223: decisions, marks, moments, labels, answers, move_answers, rewrites), loses nothing.Acceptance Criteria:
scripts/export-call-curation.mjs --call <id> --out tests/fixtures/exports/<call>.rev<N>.jsonwrites, throughscripts/with-cf-env.mjs, the call'smomentsrows that carry a mark, theirmarks, itslabels, and the utterances'i, t, t_end, speaker, words(no text), for the four samples;SELECT i, decision_json FROM decisions WHERE session_id IN ('49e3e48f-c169-458d-85a6-ae6c5c4e745d','1d160d96-9b60-480c-bb67-c146f8739b13') ORDER BY session_id, i→tests/fixtures/decisions/3339895706.v4.{49e3e48f,1d160d96}.json(pseudonymised text only;tests/unit/fixtures-clean.test.tscovers the new files). Counts quoted and expected: 3339895706 has 3 marks and 391 moments, 3303259297 one label, run 49e3e48f 96 decisions, session 1d160d96 86 decisions to i=107.scripts/remap-i.mjs --export <file> --to tests/fixtures/stitched/<call>.v2.json(dry run only here): maps a moment'sstart_iandend_ito the first and last v2 parts overlapping the old line's span, a label to the part containing the end of the old line (where its quote ends:said_booking_is_bindingat old i=89 lands on the last part, not the first), and rewritesmoment_id(which embedsstart_i,src/engine/moments.ts:114-116) and the marks'moment_id; prints the map and lists any line with no overlap.tests/unit/remap-i.test.tscovers a split turn (old i=8 → parts), a label at the end of a long rep turn (old i=89 → the last part) and a merged neighbour.docs/demo-script.mdanddocs/runbook/m1-acceptance.mdgain one line: "utterance numbers aretranscript_rev1; COPILOT-104b regenerates them".
Dependencies: COPILOT-103 (for the v2 fixture; the export itself has none and runs first)
Priority: HIGH
Executor: claude:opus
Estimate: 1 otto iteration
ID: COPILOT-104b
Title: Re-import the four samples under
STITCH_V2, move the Worker ingest to v2, re-insert the curation, measure cost [INTEGRATION-CRITICAL]Description: As Stevan, I want the four sample calls re-imported at the new granularity with my marks and labels carried over and the cost measured, so that the deployed replay shows the sentence-level cadence without losing any curation.
Acceptance Criteria:
scripts/import-call-coach.mjsandscripts/import-fixture.mjstake--stitch 2(default 2 once this lands);contentHashalready coversstitch_version(src/ingest/import-payload.ts:166-176), so the import is a COPILOT-015 replacement (--force) with a newtranscript_rev; the script refuses to run whiletests/fixtures/exports/<call>.rev1.jsonis missing.src/routes/ingest.ts:381andsrc/routes/speaker-map.ts:308callstitch(…, STITCH_V2);INGEST.stitch_version(src/policy/build.ts:120) readsSTITCH_V2.version, so the bundle's ingest version bumps with it;tests/workers/ingest.test.tsassertsstitch_version = 2on a new upload. The four earlier uploaded copies of this call (up_06337679…,up_0873645c…,up_869446e2…,up_ef077387…, each 122 lines and 96 decision points) stay v1 unless re-transcribed with COPILOT-065's{force: true}; the register says so.scripts/remap-i.mjs … --applyre-inserts the marked moments, their marks and the labels from the export at the newiin onedb.batch()withaudit_log{call.remap_i};eval/labelled/*.jsonentries keyed byiare rewritten the same way (the part containing the quote's end) andnpx vitest run tests/unit/eval-cases.test.ts tests/unit/eval-like-for-like-cases.test.tspasses.docs/demo-script.mdis regenerated from the new eval run's timeline andnode scripts/demo-check.mjs eval/reports/m1b-stitch2-<date>.l4l6.json docs/demo-script.mdexits 0; theireferences indocs/runbook/m1-acceptance.mdare re-checked.- [INTEGRATION-CRITICAL] Live probes on the deployed Worker: after the four imports,
node scripts/with-cf-env.mjs npx wrangler d1 execute copilot --remote --command "SELECT call_id, transcript_rev, COUNT(*) AS n, SUM(decision_point) AS dp FROM utterances GROUP BY call_id"showsdpbetween 125 and 145 for 3339895706 and the other three counts quoted;SELECT COUNT(*) FROM marks k JOIN moments m ON m.moment_id = k.moment_id WHERE m.call_id = '3339895706'→ 3 andSELECT COUNT(*) FROM labels WHERE call_id = '3303259297'→ 1, equal to the counts before the import;node scripts/replay-ws.mjs --call 3339895706 --scenario onboarding --speed 20→errors == 0,max_input_tokens ≤ 12000, the number ofreconnect{after_i}round trips quoted (about 5 by the arithmetic, 133 ÷ 23);node scripts/eval.mjs --scenario both --layers l4,l6 --run m1b-stitch2-<date>passes L4 and L6 withcost_by_call≤L6_LIMITS.cost_per_call_usdfor 3339895706 and 3303259297 (expected $0.047 and $0.048 against 0.05), appended todocs/reports/cost-latency.md;SELECT COUNT(*) FROM decisions WHERE session_id='<that run's session>'equalsdp.
Dependencies: COPILOT-103, COPILOT-104a
Priority: HIGH
Executor: claude:opus
Estimate: 2 otto iterations
ID: COPILOT-105
Title: Presenter: the change moment is the end of the client's run; new points on the open card move at sentence ends
Description: As a rep, I want feedback (a new point on the open card) within a sentence of what was said, while the card itself waits for the client to finish, so that sentence-level decisions feel live without the headline jumping mid-turn.
Acceptance Criteria:
web/src/presenter.ts:isClientTurnis replaced byisClientRunEnd(us, u): a clientturnwhose nextturn(skipping backchannels and acks) is not the client's, or is absent;dueForandnextClientEnduse run ends. In replay the list is complete; the function takes anuptobound so that in live (M3) it never reads beyond what has arrived and a run end becomes known when the other speaker's first fragment does. Ticks, chips, caution and purpose still apply on arrival. A plan whose current item id equals the open card's applies on arrival for any client-part decision (its new point is revealed at once, one per applied plan as today); a plan that changes the current item waits for the run end, the dwell and a quiet moment as today, except an inserted item (a concern, rate pressure, or a must-say made due by a client signal, COPILOT-111b), which skips the dwell as today (releaseAt).export const LIVE_CARD_AT_SENTENCE_END = false; when true every client part is a change moment (Stevan's decision 1b).docs/design/DESIGN.mdcadence rule 1 rewritten accordingly; rule 4 gains "a new point on the open card may appear at any client sentence end".tests/unit/web-presenter.test.tson3339895706.v2.json: a plan that switches the card, decided on a middle part of the old line 8, applies at the last part'st_end, not the middle part's; a plan adding a point to the open card, decided on a middle part, shows the point at that part'st_endplus arrival; with the flag true the switch applies at the middle part.npx vitest run tests/unit/web-presenter.test.ts→ all passed;npm run typecheckexits 0.
Dependencies: COPILOT-103
Priority: HIGH
Executor: claude:opus
Estimate: 1 otto iteration
ID: COPILOT-106
Title: Re-tune the decision-counted thresholds and the window for sentence-level cadence; set the cost gate per decision 16 [INTEGRATION-CRITICAL]
Description: As the policy owner, I want the counters that were tuned for one decision per turn re-tuned for the new cadence, gated by the eval, and the cost gate set where Stevan rules, so that concerns, purposes and cooldowns keep their real-time meaning and the gate says what it means.
Acceptance Criteria:
- Policy v5 draft:
concern_max_age8 → 12,purpose_max_decisions8 → 12,card_cooldown_utts3 → 4,token_budget.window12 → 16;COVERED_DWELL4 → 6 insrc/engine;confirm_updates,stage_confirmandema_alphaunchanged and named as kept in the story's report; soft 11,000 and hard 12,000 unchanged.tests/unit/worst-case.tsbound on the four v1 and the four v2 fixtures ≤ 12,000 estimated tokens. scripts/lib/eval-l6.ts:25cost_per_call_usdset per Stevan's decision 16 (0.06, or left at 0.05), the value quoted in the report and indocs/reports/cost-latency.md; every story's cost criterion readsL6_LIMITS.cost_per_call_usd, never a literal.- [INTEGRATION-CRITICAL]
node scripts/eval.mjs --scenario both --layers l1,l4,l6 --run m1b-tune-<date>: L1 pass rate for each scenario ≥ the value ineval/reports/recc-20260927-{onboarding,cs}.l1.json(both quoted); L4 and L6 pass;window_to_8fallbacks ≤ 11% of decisions (today 365 of 3,472);max_input_tokens ≤ 12000; the report is posted through the gate andnode scripts/publish-policy.mjspromotes v5 only on a pass.
- Policy v5 draft:
Dependencies: COPILOT-104b, COPILOT-105
Priority: HIGH
Executor: claude:opus
Estimate: 2 otto iterations
Phase B: card lifetime
ID: COPILOT-107
Title: Presenter point lifetime: 12 s floor, stacking to three, sticky last suggestion, rep-turn grace [LABEL PENDING]
Description: As a rep, I want a suggestion to stay long enough to read, later suggestions to stack under it, and never to see a point I have just made flash on, so that "say this" is usable while listening.
Acceptance Criteria:
web/src/presenter.ts:LIVE_POINT_MIN = 12,LIVE_POINTS_MAX = 3(or 2, per the board below),LIVE_LAST_SUGGESTION = 12,LIVE_REP_POINT_GRACE_S = 3;PresentedgainsrevealedAt: Record<string, number>andlastSuggestion: { line, until } | null;renderunions the current plan's points with retained points (revealed less thanLIVE_POINT_MINago), caps atLIVE_POINTS_MAXwith the oldest leaving first (at once when a new one arrives over the cap, floor or not), keeps a dropped point in its slot tinted or grey until its floor ends, except a point the rep covered (folds as today) or one the engine dropped on a clear miss (the plan'sdropped_hard, COPILOT-108), which leaves within 1 s; on a card switch the previous card's newest uncovered point becomeslastSuggestionforLIVE_LAST_SUGGESTION; a point first introduced by a plan decided on a rep turn is held until the next rep-turn decision arrives orLIVE_REP_POINT_GRACE_Spasses (a client-turn decision in between, such as line 10, does not release it: only rep turns carrycovered::,src/engine/plan.ts:302-305), and renders grey without the tint if that decision carriescovered_atfor it.web/src/plan.tsrenderslastSuggestionas one grey line under the new card with the labelplan.last_suggestion("Last suggestion", proposed inpolicy/proposals/2026-09-27.json); until the label is approved the line is not rendered and the test below skips its assertion.docs/design/DESIGN.md§Cadence rules gains rules 10 (point floor and its two exceptions), 11 (stacking, oldest first) and 12 (sticky last suggestion, rep-turn grace), and the constants table gains the four values.tests/unit/web-presenter.test.tsreplaystests/fixtures/decisions/3339895706.v4.49e3e48f.json(COPILOT-104a) with arrival =t_end+ 0.3 s at a 0.1 s clock: the amount point (decided at i=9, t_end 99.2 s) is never rendered tinted (its tick arrives with i=11 at 100.9 s + 0.3, inside the grace); every rendered point's on-screen time is ≥ 12 s unless covered or hard-dropped; at mostLIVE_POINTS_MAXpoints at any tick; after the switch at 134.1 s a "Last suggestion" line shows the first-transfer-date point for 12 s (asserted only when the label is approved).npx vitest run tests/unit/web-presenter.test.ts tests/unit/web-plan.test.ts→ all passed.- [UI] Before the screenshots, a shotgun board (
/design-shotgun) of the stacked-points card with two and three points, sign-off recorded indocs/design/evidence/COPILOT-107-board.md, fixingLIVE_POINTS_MAX. Then screenshots both themes, deployed, 3339895706 at 1x clock 01:52 (stacked points) and 02:16 (last suggestion, once its label is approved):docs/design/evidence/COPILOT-107-{light,dark}.png.
Dependencies: COPILOT-104a, COPILOT-105
Priority: HIGH
Executor: claude:opus
Estimate: 2 otto iterations
ID: COPILOT-108
Title: Engine: situation hysteresis in the uncertain band, and pre-judged fits and choice for the next item [INTEGRATION-CRITICAL]
Description: As the engine, I want a shown situation to survive one uncertain answer but leave at once on a clear miss, and the next item's situations judged before it opens, so that cards open with points and do not flicker on one borderline answer.
Acceptance Criteria:
src/engine/situation.ts:SituationFitgainsshown: string | nullandmisses: number; a new thresholdsituation_fit_hard: 0.3inrules-*.json(underweights_hash);recordFits(:184-194) incrementsmisseswhen the shown situation's answer is in[situation_fit_hard, situation_fit)or the question was dropped, resets it at or abovesituation_fit, and clearsshownat once belowsituation_fit_hard;shownSituationId(:228-246) keepsshownwhilemisses < 2unless another candidate qualifies at or abovesituation_fit; the plan carriesdropped_hard: [text_id]for the presenter.fitsToAsk(:152-157) also asks, on client turns,fits::for up to three situations of the item that would become current next (the ranking's leading challenger, or the next open item) and, when that item has two or more candidates, itssituation::<move>Choice (shownSituationIdneeds both);retargetFit(:205-214) seeds the new item'sfitsandchoicefrom those answers instead of{}andnull. The pre-judge questions are a list of their own inplanRequestwith a code-rule drop stepprejudgeahead offact_values(src/engine/budget.ts:117-123), recorded inbudget.dropped.tests/unit/engine-situations.test.ts,engine-fit.test.ts: a shown situation survives one answer of 0.45 and leaves on the second; leaves at once on 0.05;ask_about_the_transfer(two situations) opening after a pre-judgedfits::amount_not_said≥ 0.6 and a Choice ofamount_not_saidat ≥ 0.4 has a point on its first decision;tests/unit/engine-budget.test.ts:prejudgeis dropped beforefact_values;tests/unit/worst-case.ts≤ 12,000 with the extra questions.docs/policy/like-for-like.md§3.3 gains the amendment (rules 1-2 hold for entering; a shown situation leaves per the band rule) marked "pending Stevan's decision 17".npx vitest run tests/unit/engine-*.test.ts→ all passed.- [INTEGRATION-CRITICAL]
node scripts/replay-ws.mjs --call 3339895706 --scenario onboarding --speed 20→errors == 0,max_input_tokens ≤ 12000;node scripts/ws-probe.mjs --call 3339895706 --scenario onboarding --speed 20 --count-points(new flag: counts decisions whose current item has at least one uncovered point, the distinct items that ever had one, and points withdrawn within 12 s of appearing) prints the three numbers; expected at least 20 decisions and 5 items (today 12 and 3); the measured values and the cost delta of the pre-judge questions (expected about $0.003-0.005 per strong call;cost_by_callagainstL6_LIMITS.cost_per_call_usd) recorded indocs/reports/cost-latency.md.
Dependencies: COPILOT-104b, COPILOT-106
Priority: HIGH
Executor: claude:opus
Estimate: 2 otto iterations
ID: COPILOT-109 (conditional on Stevan's decision 11, option B)
Title: Show the best-fitting situation's core point at a lower fit when nothing clears 0.6
Description: As a rep, I want the card to carry the most likely situation's core point when the gate is close but not sure, so that "Listening" is rarer, while an approved line still shows only for the situation it was said in.
Acceptance Criteria:
thresholds.situation_fit_soft: 0.4(rules-*.json): when no candidate meetssituation_fit, the Choice's pick (or the only candidate) shows its core point only (core: true) when its fit is in[situation_fit_soft, situation_fit), tagged "Worth saying" as today; nothing shows below 0.4. No flat lines are restored:docs/policy/like-for-like.md§2 counts 0 approved flat lines (the restructure moved every approved line into a situation; the flat lines left are drafts) and §3.3 rule 3 stands below 0.4. The loosening is recorded in like-for-like §3.3 under Stevan's decision 11B.tests/unit/engine-situations.test.ts: a fit of 0.5 shows the core point only; a fit of 0.35 shows nothing; the delivery-date must-not case (like-for-like §4.1) still shows nothing;eval/cases/like-for-likegains a MUST-NOT case per scenario at a 0.35 fit. Measured on the v2 replay: decisions with an uncovered point ≥ 40 of about 133, recorded.
Dependencies: COPILOT-108
Priority: MEDIUM
Executor: claude:opus
Estimate: 1 otto iteration
ID: COPILOT-120
Title: Player status has a reserved slot: "catching up, N queued" never moves the scrubber [UI]
Description: As a rep, I want the player bar to stay still when the session falls behind and catches up, so that the scrubber does not jump left and right while I watch.
Acceptance Criteria:
web/src/player.ts(and the CSS): the status text ("catching up, N queued", "live", "paused") renders in a slot whose width is reserved whether or not the text is present, or as an overlay positioned above the bar; the scrubber's bounding box is identical with and without the status (a Playwright check that readsgetBoundingClientRect()before and after forcing the status on,npx vitest run tests/unit/web-player.test.ts→ all passed).- [UI] Design artifact:
docs/design/DESIGN.md§Player (add the slot rule); screenshots both themesdocs/design/evidence/COPILOT-120-{light,dark}.pngwith the status visible.
Dependencies: COPILOT-046
Priority: MEDIUM
Executor: claude:opus
Estimate: 1 otto iteration
Phase C: plan semantics and pre-call facts
ID: COPILOT-110a
Title: Policy grammar: item kinds,
applies,due,satisfied_by,surface_when, the derived must-say checklist; wording unchangedDescription: As the policy owner, I want every plan item typed as a fact, a must-say or a judgement call with the rules that satisfy or surface it, and the must-say checklist derived from the must-say items, so that the plan can be a guide rather than a checklist and the two lists cannot drift, with no wording change and every build green.
Acceptance Criteria:
policy/src/plan-onboarding.jsonandplan-customer-success.json: every template item carrieskind: 'fact' | 'must_say' | 'judgement'; facts carrysatisfied_by { slots[], rule? }and optionalsurface_when; must-says carryapplies(always, orslot_inwithor_unknown),due(asignal, ananyof signals, or a slot test) and adone_whenwith amust_sayid; judgement items carrysurface_whenand nodone_when. Every signal, slot and must-say id named must exist in the bank;src/policyloader rejects any other combination naming the item. The tenfirst_call_after_signupitems carry the kinds ofFEEDBACK-2026-09-27.md§2d:confirm_funding_accounta must-say withapplies: slot_in funding_account [third_party, company_account], or_unknown;agree_next_stepa fact locked bynext_step_agreed(client turns only,rules-onboarding.json:308);explain_who_holds_the_moneydue onclient_asked_about_safetyorclient_ready_to_book(decision 2c:alwaysif Stevan picks the first option). Titles, hints and labels are unchanged.scripts/build-policy.mjsderives the onboarding must-say checklist from themust_sayitems (replacing the hand list inrules-onboarding.json:20-104);said_payment_reason,said_quote_lifetime,said_recording_disclosureandsaid_rm_contactleave the onboarding checklist per the 2026-09-25 and 2026-09-27 rulings;said_rate_transparencystays attached to the rate situations.bank_hashandplaybook_hashunchanged (asserted intests/unit/policy-build.test.ts); onlyweights_hashchanges.npx vitest run tests/unit/policy-plan.test.ts tests/unit/policy-build.test.ts tests/unit/policy-src.test.ts tests/unit/policy-approved.test.ts→ all passed;npm run typecheckexits 0.
Dependencies: none
Priority: HIGH
Executor: claude:opus
Estimate: 2 otto iterations
ID: COPILOT-110b
Title: The
bank_countryslot and the served-country list (a bank change) [INTEGRATION-CRITICAL]Description: As the engine, I want to know the country of the bank the client names, and which countries the partners serve, so that "Where they live and bank" can be satisfied from the call and surfaced only when a country is off the served list.
Acceptance Criteria:
policy/src/plan-onboarding.jsonfact_slotsgainsbank_country(questionfact_bank_country, closed list = the gazetteer's country ids,known_fact: bank_country_known, chip templateplan.fact.bank_country.template"Banks in {country}", proposed inpolicy/proposals/2026-09-27.jsonand not rendered until approved);check_serviceability_first.satisfied_by.slotsbecomes[residency, bank_country]withrule: servedreadingserved_countries: []in the scenario bank (empty: a known residency satisfies the fact and nothing is "outside served"; non-empty: a residency or bank country off the list surfaces the card and lets the jurisdiction concern open).served_countriesis filled only from Stevan's or the partners' list (open question 9).- This is a bank change:
bank_hashchanges; one L1 case per scenario on line 38 ("Saudi National Bank" →SAat ≥ 0.6);tests/unit/engine-fact-values.test.tscovers the slot;node scripts/probe-questions.mjsshows the new question answered on the deployed Worker. - [INTEGRATION-CRITICAL]
node scripts/eval.mjs --scenario onboarding --layers l1 --run m1b-bank-country-<date>: the new case passes with the measured confidence quoted; the onboarding L1 pass rate ≥ the 2026-09-27 baseline.
Dependencies: COPILOT-110a
Priority: MEDIUM
Executor: claude:opus
Estimate: 1 otto iteration
ID: COPILOT-110c
Title: Apply Stevan's reply on the 2026-09-27 wording (human-gated)
Description: As the policy owner, I want the proposed titles, hints, notes and new labels to enter
policy/srconly as Stevan approved them, in one commit, so that no build ever carries a draft.Acceptance Criteria:
policy/proposals/2026-09-27.jsonholds every proposed text ofFEEDBACK-2026-09-27.md§2d: for existing ids, today's text and the proposal; the new idsplan.head.title,plan.head.subtitle,plan.known.signup,plan.known.call,plan.known.demo,plan.fact.bank_country.template,plan.last_suggestion,plan.concern.raised_again; and the situationaddress_ties_me_to_tax_residency(headline, hint, one point with{country}and aresidencyslot requirement, marked [VERIFY]), which stays out ofpolicy/srcuntil decision 13 is answered, since the loader rejects the marker.scripts/labels-sheet.mjs --proposals policy/proposals/2026-09-27.jsonrenders a "2026-09-27 proposals" section onpolicy/LABELS.md(today → proposed, with the [VERIFY] line excluded from "approve all").- On Stevan's reply, saved as
policy/approvals/2026-09-27-labels.jsonin the format of2026-09-26-labels.json:node scripts/apply-label-approvals.mjs policy/approvals/2026-09-27-labels.jsonrewords the existing ids, and the approved new ids are added topolicy/srcalreadystatus: 'approved'with his email and the reply date, in the same commit; a "rejected" or unanswered new id is not added. The executor stops withpasses: falseand the note "awaiting Stevan's reply on policy/LABELS.md" if the approvals file is absent; it never stamps an approval itself (COPILOT-023's human gate). npx vitest run tests/unit/policy-approved.test.ts tests/unit/policy-drafts.test.ts tests/unit/rep-facing-text.test.ts→ all passed; policy v5 built, and promoted bynode scripts/publish-policy.mjson the next passing eval run.
Dependencies: COPILOT-110a, Stevan's reply
Priority: HIGH
Executor: claude:opus
Estimate: 1 otto iteration
ID: COPILOT-111a
Title: Engine: fact and judgement kinds, silent facts,
CallPlan.known[]Description: As the engine, I want facts to tick silently from any source into a known list with their source, and judgement items to exist only while triggered, so that the live list holds only what helps right now.
Acceptance Criteria:
src/engine/plan.ts: afactitem whosesatisfied_byholds (slots known at or abovefact_value_min_confor seeded at evidence i=-1, andrule: servedtrue or the served list empty) moves toCallPlan.known[]{ slot, label, source: 'signup' | 'crm' | 'call' | 'demo', at }and out ofitems; it re-enters as current whensurface_whenfires. Ajudgementitem has no row untilsurface_whenfits, is neverdoneornot_needed, and leaves when the trigger stops fitting (the presenter's floor covers the read).src/session/protocol.ts(HOT FILE, additive):CallPlan.known.tests/unit/engine-plan.test.tson the v2 fixture with stub answers:open_with_agendanever appears as an unticked row;check_serviceability_firstis inknownonce residency is known;explain_settlement_and_cutoffhas no row untilclient_asked_about_timingscores.npx vitest run tests/unit/engine-plan.test.ts tests/unit/session-protocol.test.ts tests/unit/engine-purity.test.ts→ all passed;npm run typecheckexits 0.
Dependencies: COPILOT-110a
Priority: HIGH
Executor: claude:opus
Estimate: 2 otto iterations
ID: COPILOT-111b
Title: Engine: must-say
appliesanddue, no tallies or not-needed rows in the live plan, the minimum stopgapDescription: As the engine, I want must-says listed only while they apply and surfaced when due, the live plan free of tallies and not-needed rows, and a second amount to keep the minimum item open, so that the live list is a guide and the review keeps the score.
Acceptance Criteria:
src/engine/plan.ts: amust_sayitem is initemsonly whileappliesholds,lateruntilduefires, then current; whenduefired on a client signal the item carriesorigin: 'client'so the presenter'sisInserttreats it as an inserted item (no dwell,web/src/presenter.ts:164-170);minimum_transferis nevernot_neededonceamountorcurrency_pairhas been updated during the call (it stayslater, and the review row reads "a second amount was heard"); the livedecision.plancarries nonot_neededitems and no tally; the review payload keeps both (protocol additive).tests/unit/engine-plan.test.tson the v2 fixture with stub answers: at the last decision of 3339895706 the liveitemshas at most 4 open rows; the review tally reads3 of 4applied must-says (or2 of 3whenminimum_transferis not applicable under the policy's per-leg ruling constant, decision 2e);explain_who_holds_the_moneybecomes due on the line scoringclient_asked_about_safety≥ 0.6 ("is it gonna be safe", 08:16) withorigin: 'client'; a stubbed second amount keepsminimum_transferatlater.npx vitest run tests/unit/engine-plan.test.ts tests/unit/session-protocol.test.ts→ all passed.
Dependencies: COPILOT-111a
Priority: HIGH
Executor: claude:opus
Estimate: 2 otto iterations
ID: COPILOT-112
Title: Pre-call facts:
precall_jsonon calls and sessions, closed-vocabulary validation, import field and service-token routeDescription: As Stevan, I want facts known before a call stored against the call in a closed vocabulary with their source and snapshotted per session, so that the engine can start from what is already known without any free text entering the system.
Acceptance Criteria:
migrations/0004_precall.sql:ALTER TABLE calls ADD COLUMN precall_json TEXT; ALTER TABLE sessions ADD COLUMN precall_json TEXT;src/ingest/precall.tsparsePrecall(json, policy): Precallaccepts a key only when it is a fact slot of the policy with a closed label list (client_type,residency,transfer_purpose,frequency,documents_status,current_provider,funding_account,timing,bank_countryonce COPILOT-110b adds it) and its value is one of that slot's label ids, pluscurrency_pairas two ISO 4217 codes from the bank's list,amountas{ digits, code }, andsource ∈ {signup, crm, demo}(required); any other key or value → 400invalid_precallnaming the field. The type has no free-text field.scripts/precall-payload.mjs --call <id> --source demogenerates a payload from the policy's label ids, so no probe hand-types one.- Sources:
precallin the import payload (scripts/lib/import-payload.schema.json,src/ingest/import-payload.ts) andPUT /api/calls/:id/precall(service token only, auditedcall.precall_set, one dispatch line insrc/index.tsHOT FILE); the route acceptssource: demo(andcrm) only;signupis written by the M2 join. Session start (src/session/handlers/load.ts) copiescalls.precall_jsonintosessions.precall_jsononce; a later PUT never changes an existing session. The CT sign-up join throughpd_ct_idis M2 (3485591407 has the key, 109829); this story fixes the shape it must produce. tests/unit/pseudonymised.test.tsextended: a precall with a name-shaped string in any field is rejected;tests/workers/precall.test.ts: PUT then session load snapshots; a second PUT leaves the session's copy unchanged;"transfer_purpose":"salary"→ 400 (salary_or_pensionis the id).- [INTEGRATION-CRITICAL] Live probe:
node scripts/with-cf-env.mjs npx wrangler d1 migrations apply copilot --remoteapplies 0004;curl -s -X PUT "$WORKER_URL/api/calls/3339895706/precall" -H "CF-Access-Client-Id: $CF_ACCESS_CLIENT_ID" -H "CF-Access-Client-Secret: $CF_ACCESS_CLIENT_SECRET" --data "$(node scripts/precall-payload.mjs --call 3339895706 --source demo)", which sends{"source":"demo","client_type":"personal","residency":"SA","currency_pair":["SAR","NZD"],"transfer_purpose":"salary_or_pension","frequency":"monthly","documents_status":"proof_of_address_outstanding","amount":{"digits":"25000","code":"NZD"}}(the values this call itself establishes, the amount the low end of the 25-30k NZD of line 13; itspd_ct_idis NULL, hencedemo) → 200; the same with"residency":"Narnia"→ 400;SELECT precall_json FROM calls WHERE call_id='3339895706'quoted.
Dependencies: none (COPILOT-110b for
bank_country, optional)Priority: HIGH
Executor: claude:opus
Estimate: 2 otto iterations
ID: COPILOT-113
Title: Engine seeding from pre-call facts at evidence i=-1, with their source; the served-list gate on the jurisdiction concern [INTEGRATION-CRITICAL]
Description: As the engine, I want the session to start from the pre-call facts, each shown with its source, so that items satisfied before the call never nag, and the jurisdiction concern cannot open for a country on the served list.
Acceptance Criteria:
src/engine/state.tsinitialState(p, precall?)seedsfact_values[slot] = { value, conf: 1, i: -1, t: 0, source },client_type, andpersisted[known_fact] = { i: -1, value: 1 }for slots with aknown_fact; the four callers pass the session snapshot (src/session/handlers/load.ts:131,seek.ts:55,checkpoint.ts:254,src/engine/recompute.ts:73).buildJevStateincludes the seeded facts inknown_facts.factChipsmarks a seeded slot with the snapshot'ssource; the view never builds a time link for i=-1; a confident different value on the call replaces a seeded one and the chip reads "updated" (the existing rule). Params stay ids andweb/src/render.ts:87keeps rendering the display name ("Saudi Arabia"), asserted unchanged intests/unit/web-plan.test.ts.src/engine/concern.ts: ajurisdictionraise is ignored whileresidencyis known and in the scenario'sserved_countries(COPILOT-110b; empty inpolicy/srcuntil Stevan supplies the list, so the gate is inert on the live call); never keyed on "known" alone.tests/unit/engine-concern.test.tsuses a fixture bank withserved_countries: ['SA']for the gated case, and asserts that withserved_countries: ['GB']and residencySAajurisdiction_concernanswer of 0.9 still opens an episode.tests/unit/engine-state.test.ts,engine-plan.test.ts: with the 3339895706 precall,check_serviceability_firstandunderstand_the_transferare inknownat i=0 (understand_the_transferneeds purpose, amount and frequency, all in the PUT);engine-purity.test.tsstill passes.npx vitest run tests/unit/engine-*.test.ts→ all passed.- [INTEGRATION-CRITICAL] After COPILOT-112's PUT:
node scripts/replay-ws.mjs --call 3339895706 --scenario onboarding --speed 20→errors == 0;node scripts/ws-probe.mjs --call 3339895706 --scenario onboarding --firstprints the first decision'splan.knownwith 7 entries taggeddemo(one per seeded slot) and nocheck_serviceability_firstrow;SELECT COUNT(*) FROM decisions WHERE session_id='<that session>' AND decision_json LIKE '%concern:jurisdiction:%'quoted: with the empty served list the concern still opens once (one row after COPILOT-117), and the count reads 0 only once SA is on the list; both cases recorded in the register.
Dependencies: COPILOT-111a, COPILOT-112 (COPILOT-110b for the served list; until it lands the gate reads an empty list)
Priority: HIGH
Executor: claude:opus
Estimate: 2 otto iterations
ID: COPILOT-114
Title: Live view: known chips with their source, the guide framing, no tallies, collapsed covered rows [LABEL PENDING]
Description: As a rep, I want the plan to read as a guide: what we already know as chips, one card, up to three things coming up, and what I have covered in one line, so that it never looks like a score sheet.
Acceptance Criteria:
- [UI]
web/src/chips.tsrendersplan.known[]; the source suffix (plan.known.signup"From sign-up",plan.known.call"From the call",plan.known.demo"Seeded for the demo") renders once the label is approved, the chip alone until then.web/src/plan.ts: head titleplan.head.title("Guide for this call") withplan.head.subtitleonce approved, today's "Plan for this call" until then; no tally; no "Not needed on this call" group; done rows collapse into one expandable "Covered: Documents still needed 04:39 · Booking is binding 07:44" line (today's titles until the new ones are approved); "Coming up" shows at most three applying must-say rows, grey, without a state word.export const LIVE_LIST_MODE: 'A' | 'B' | 'C' = 'A'(Stevan's decision 2b): B renders chips and the card only; C keeps G's list with kinds. docs/design/DESIGN.md§Plan pane and §Not needed on this call rewritten; §Removed from the live view gains the tally and the not-needed group;FEEDBACK-2026-09-27.md§2d referenced.- [UI] A shotgun board of the guide layout (modes A and B) before the screenshots, sign-off recorded in
docs/design/evidence/COPILOT-114-board.md.npx vitest run tests/unit/web-plan.test.ts tests/unit/web-chips.test.ts→ all passed; screenshots both themes at 1440x900 of the deployed dashboard replaying 3339895706 at 02:45 and 08:20:docs/design/evidence/COPILOT-114-{light,dark}.png.
- [UI]
Dependencies: COPILOT-111b, COPILOT-113
Priority: HIGH
Executor: claude:opus
Estimate: 2 otto iterations
ID: COPILOT-115
Title: Review view: completeness as coaching, with "covered before the guide suggested it"
Description: As a manager or a rep reading a call afterwards, I want the must-says scored against what applied, every miss linked to its moment with the suggested line, and the times the rep was ahead of the guide called out, so that the review builds trust rather than policing.
Acceptance Criteria:
- [UI]
web/src/review-plan.tsshows: "Must-says: N of M that applied", one row each with why it applied, when it became due (time link), when it was said (time link) or "Missed" with the suggested line and the moment link; "Known at the end: N of M" with the source counts; no score for judgement items (never counted); "Covered before the guide suggested it: N of M"; "a second amount was heard" on the minimum row when COPILOT-111b's stopgap fired; a must-say due only in the last rep turn is not counted; the panel is labelled "reconstructed from the decisions" where live presentation is inferred (COPILOT-049's caveat). tests/unit/web-review-plan.test.tson the 3339895706 v2 fixture with stub answers:3 of 4(or2 of 3), the miss row forexplain_who_holds_the_moneylinking to the "is it gonna be safe" line,2 of 2ahead of the guide.npx vitest run tests/unit/web-review-plan.test.ts→ all passed; screenshots both themesdocs/design/evidence/COPILOT-115-{light,dark}.png.
- [UI]
Dependencies: COPILOT-111b, COPILOT-113
Priority: MEDIUM
Executor: claude:opus
Estimate: 2 otto iterations
ID: COPILOT-116
Title: Transfer-purpose trigger words and the salary-home prior [INTEGRATION-CRITICAL]
Description: As the engine, I want "I get paid on the fifteenth" to trigger the purpose question and a Saudi salary sender's likely situations ranked first, so that the purpose is captured on calls like 3339895706.
Acceptance Criteria:
policy/src/plan-onboarding.jsontransfer-purpose trigger (:176) gainspaid|pay ?day|pay home|earn(ings)?|income; the question and its labels are unchanged (bank hash unchanged, no re-ask cost).plan.priors[]:{ when: { sell_in: ['SAR', 'AED'], residency_in_sell_country: true }, likely: { transfer_purpose: 'salary_or_pension' }, ranks: [<existing situation ids>] }; a prior only reorders situation candidates and never writes a slot or a chip (tests/unit/engine-fact-values.test.tsasserts no chip from a prior alone); the three Saudi-specific situations (test transfer first, first-payment delays, address worries) are listed as M2 harvest drafts indocs/policy/like-for-like.md, not encoded.tests/unit/engine-fact-values.test.ts: the old line 71 text triggersfact_transfer_purpose;eval/casesgains an L1 case on that line expecting thesalary_or_pensionlabel at or above 0.6.- [INTEGRATION-CRITICAL]
node scripts/eval.mjs --scenario onboarding --layers l1 --run m1b-purpose-<date>: the new case passes with the measured confidence quoted; the onboarding L1 pass rate ≥ the 2026-09-27 baseline.
Dependencies: COPILOT-110a
Priority: MEDIUM
Executor: claude:opus
Estimate: 1 otto iteration
Phase D: the duplicate tick
ID: COPILOT-117
Title: Concern episodes: never clear on a line under 4 words; reopen the same row within 60 s [INTEGRATION-CRITICAL] [LABEL PENDING]
Description: As a rep, I want a three-word fragment never to close a concern, and a concern that comes back seconds after it closed to reopen the same row, so that a single worry is one row with one tick.
Acceptance Criteria:
src/engine/concern.ts:120: clearing requires a client utterance withwords ≥ 4; theclient_acceptssignal andconcern_clearare unchanged (the guard only says which lines may carry the clear); no punctuation or run-end test, since the engine cannot see the next turn (lastOfRunlives only institch(),stitch.ts:190).concern.ts:110: a raise of the sametypewithinthresholds.concern_reopen_window_s(policyrules-*.json, default 60 s of call time, compared onu.t) of the last close keeps the previousstart_iand setsreopened_at: u.i,raises + 1.src/engine/plan.ts:445-459: an existing episode row with thatstart_ireturns tocurrentwithdone_at: nullandreopened_at; its state word reads the existing "Raised by the client" with the new time, andplan.concern.raised_again("raised again", proposed inpolicy/proposals/2026-09-27.json) once approved.tests/unit/engine-concern.test.ts,engine-plan.test.tson the recorded answers of run49e3e48f(tests/fixtures/decisions/3339895706.v4.49e3e48f.json, COPILOT-104a): exactly oneconcern:jurisdiction:54row, raised at 308.5 s,raises2 (line 58,client_objecting0.79, re-raises the still-open concern), done at 364.3 s on line 64 (4 words,client_accepts0.72); line 56 (3 words,client_accepts0.74) does not clear it. On a synthetic transcript (a clear on a 5-word accepting line, a re-raise 20 s later, another 90 s later): the first re-raise reopens the same row withreopened_atanddone_at: null; the second opens a new row.npx vitest run tests/unit/engine-concern.test.ts tests/unit/engine-plan.test.ts→ all passed.- [INTEGRATION-CRITICAL]
node scripts/replay-ws.mjs --call 3339895706 --scenario onboarding --speed 20→errors == 0; on the last decision of that session,SELECT decision_json FROM decisions WHERE session_id='<id>' ORDER BY i DESC LIMIT 1contains exactly oneconcern:jurisdiction:item id (quoted) withraises: 2; the review view's transcript shows one covered tag for it. This holds with or without COPILOT-113's gate while the served list is empty. (Recompute is not used:concern_openfeeds the state Jev saw.)
Dependencies: COPILOT-104a (the fixture; the code has none)
Priority: HIGH
Executor: claude:opus
Estimate: 1 otto iteration
ID: COPILOT-119
Title: Rate concern needs a CurrencyTransfer price: the concern classifier excludes complaints about the client's current provider [INTEGRATION-CRITICAL]
Description: As a rep, I want "Client thinks our rate is too high" to open only when the client pushes back on a CurrencyTransfer price, so that a complaint about their own bank's rate (call 3339895706, line 30, 02:22) never becomes a plan row.
Acceptance Criteria:
policy/src/shared.json(or the scenario bank that holds it): the concern-type Choice's criteria forratesay that a complaint about the client's current bank or provider, with no CurrencyTransfer price mentioned, is not a rate concern (the same exclusion COPILOT-058e wrote intorate_pressure_raised);src/engine/concern.tsopens a concern of typerateonly whenclient_objectingorrate_pressure_raisedis on at that decision; the bank version and hashes bump as the build requires; Jev-facing text only, no red-pen needed.eval/labelled/onboarding.jsongains the line-30 case (context lines 27-29, latest line 30) withconcern_type != rateandrate_pressure_raised < 0.4, plus one positive case where the client objects to a CurrencyTransfer quote andconcern_type == rate;npx vitest run tests/unit/engine-concern.test.ts tests/unit/eval-cases.test.ts→ all passed.- [INTEGRATION-CRITICAL] After
build-policy --next-version,publish-policy(draft) andnode scripts/eval.mjs --scenario both --layers l1,l4,l6 --policy-version <N> --fresh --run rate-concern-<date>→L1 onboarding ≥ 90 %,L1 cs ≥ 90 %,L4: PASS,evaluated=true(quoted), then publish; a freshnode scripts/replay-ws.mjs --call 3339895706 --scenario onboarding --speed 20whose last decision'splan.itemshas noconcern:rate:*row before line 40 (quoted from D1).
Dependencies: COPILOT-104a (the fixture), COPILOT-058e
Priority: HIGH
Executor: claude:opus
Estimate: 1 otto iteration
Phase E: verification
- ID: COPILOT-118
- Title: M1b verification, live-probe register and acceptance runbook [INTEGRATION-CRITICAL]
- Description: As Stevan, I want one pass that re-runs every gate, replays the four samples on the deployed Worker, records the measured cadence and card numbers against the 2026-09-27 baseline, and gives me a two-minute acceptance walk, so that M1b is demonstrably done.
- Acceptance Criteria:
npm run typecheck,npx vitest run,npx vitest run -c vitest.workers.config.ts,node scripts/with-cf-env.mjs npx wrangler deploy --dry-run,npm run site,node scripts/check-secrets.mjsall exit 0 (counts quoted).- [INTEGRATION-CRITICAL]
node scripts/replay-ws.mjs --call <id> --scenario <s> --speed 20for the four samples →errors == 0, one model id,max_input_tokens ≤ 12000; the same run without--eval-uidwarms the answer cache before the 1x walk, so its seeks are hits; 3339895706 at 1x in sync with its MP3 (scripts/demo-check.mjs);docs/verification/M1b.md= the register with one row per INTEGRATION-CRITICAL story (104b, 106, 108, 110b, 112, 113, 116, 117) and a measured table against the baseline: decision points, longest and p90 gap (on estimated word times; on nova-3 word times too if open question 11 lands), decisions with an uncovered point, distinct items with points, shortest point display, points withdrawn within 12 s of appearing, spoken-to-visible wait (word-weighted) and sentence-end-to-first-feedback p50/p95 at 1x (measured by the replay client from arrival and the presenter's reveal), cost per call againstL6_LIMITS.cost_per_call_usd, end-to-end p95 at 1x, the jurisdiction row count (1 with the empty served list; 0 once SA is on it), and the uploaded copies' stitch version (v1 unless re-transcribed). docs/runbook/m1b-acceptance.md: the walk of 3339895706 at 1x with what to look for at 00:45-01:30 (line 8 appearing word by word, a chip within a sentence of "SAR to New Zealand dollars", the amount question arriving grey, not tinted), 02:41 (the CAD leg: the minimum item stays open once the second amount is heard), 05:08-06:04 (one concern row, "raised" then "raised again" then "done"), 08:16-08:56 (the who-holds-the-money card opening on "is it gonna be safe" and holding its point); the decisions still open for Stevan copied fromFEEDBACK-2026-09-27.md.docs/design/DESIGN.mdconstants table and cadence rules match the code;docs/reports/cost-latency.mdhas the M1b section.
- Dependencies: COPILOT-102, COPILOT-104b, COPILOT-106, COPILOT-107, COPILOT-108, COPILOT-111b, COPILOT-113, COPILOT-114, COPILOT-115, COPILOT-116, COPILOT-117, COPILOT-119, COPILOT-120
- Priority: HIGH
- Executor: claude:opus
- Estimate: 2 otto iterations
Sizing and order
| Phase | Stories | Otto iterations | Blocked on |
|---|---|---|---|
| A live granularity | 102, 103, 104a, 104b, 105, 106 | 9 | decisions 1a (rule), 1b (flag default) and 16 (cost gate) |
| B card lifetime | 107, 108, (109), 120 | 5 (+1) | decision 17 (band rule); decision 11 option B for 109; the shotgun board for 107 |
| C plan semantics | 110a, 110b, 110c, 111a, 111b, 112, 113, 114, 115, 116 | 17 | Stevan's reply on the wording (110c; 114 renders today's words until then), decisions 2a-2h, the served list (110b) |
| D duplicate tick and the rate false positive | 117, 119 | 2 | 119 on 104a |
| E verification | 118 | 2 | all |
| Total | 22 (+1) | 35 (+1) |
Start with 102, 103, 104a, 110a, 112 and 117 in parallel: no dependencies between them (117's test needs 104a's fixture; its code does not), and 117 edits rules-*.json while 110a edits plan-*.json, so they do not collide. The critical path is 103 → 104b → 106 → 108 → 118 (about 10 iterations). 110c waits on Stevan and gates nothing but the new wording.
8. Risks and open questions (f)
Risks
| Risk | Effect | Mitigation |
|---|---|---|
A forced re-import deletes every derived row of the call (calls-import.ts:24: decisions, marks, moments, labels, answers) and renumbers i; marks, labels, the L1 ground truth, the demo script and the acceptance runbook are keyed by i |
Stevan's session and the curation gone, L1 cases and the demo wrong | COPILOT-104a exports the two sessions and the curation before anything is re-imported; 104b re-inserts the marks and labels remapped by time overlap (a label to the part holding its quote's end) and regenerates the demo script; the v1 fixtures stay; a probe compares mark and label counts before and after |
| Every state change (seeded facts, hysteresis, pre-judge, concern) invalidates the cache | a paid re-run per story, about $0.05 a call | trivial in money; each story's eval run is fresh |
| The strong calls land at $0.047-0.048 after the split and about $0.050-0.053 with the pre-judge questions | the $0.05 per call gate (eval-l6.ts:25) trips |
decision 16: Stevan re-sets the gate to $0.06 (COPILOT-106 edits L6_LIMITS) or keeps it, in which case prejudge is the first drop and 108 measures what that costs in empty cards |
| Word times inside a fragment are interpolated | a pause inside a long fragment (up to 41 s here) shifts the words after it by the pause's length; the v2 gap figures inherit the estimate | exact enough on short lines; real times arrive with nova-3 uploads (open question 11) and in M3, and 118 reports the gaps again on them |
| Hysteresis keeps an uncertain situation one decision longer | one stale point for one decision, only for fits in [0.3, 0.6) | a clear miss (under 0.3) still leaves at once; measured in COPILOT-108 |
| Three stacked points in G's card | about 18 s of reading at 2.5 words a second while listening | a shotgun board before 107's and 114's screenshots decides between three and two; oldest leaves first |
| Pre-call facts can be wrong (the client changed plans since sign-up) | a stale chip | a confident different value on the call replaces it with "updated"; a disagreement surfaces the fact card |
| The jurisdiction gate needs the partners' served-country list | until then the gate is inert and the concern still opens on this call (one row after 117) | Stevan supplies the list (open question 9); the gate is never keyed on "known" alone, or the "can't serve" exit is lost |
| The new wording waits on Stevan's reply | the guide framing (114) and the sticky and reopen lines (107, 117) ship with today's words until then | proposals stay outside policy/src, so every build stays green; 110c applies the reply in one commit |
| More decisions at 20x hit the subrequest guard sooner | about 5 reconnects per call instead of 4; a cold seek late in a long call still re-steps 0..i in one invocation (architecture §4 follow-up) | the COPILOT-058b path; measured in 104b; 118 warms the cache at 20x before the 1x walk |
| Uploaded calls keep 45 s turns if the Worker ingest stays on v1 | uploads never show the sentence cadence | 104b moves ingest.ts:381 and speaker-map.ts:308 to STITCH_V2 and bumps the bundle's ingest version (build.ts:120); the four existing uploaded copies of this call stay v1 unless re-transcribed with COPILOT-065's {force: true} |
| Run-to-run variance on cache misses (residency in 2 of 4 runs, rate versus fees at line 30) | the next replay differs from the last | seeding removes the residency variance; the rest is Jev's, tracked by the eval |
Open questions
- Which reference mode did Stevan compare against: replay (silences capped at 6 s) or the live mic (interim words)? It decides whether T1 alone answers most of point 1.
- Should the card itself switch at a sentence end (flag default in COPILOT-105)? Recommended no.
- Raise the hard token cap from 12k to about 16k so the window can cover the reference's span (about 160 s)? Cloudflare served requests up to 15,674 tokens with flat latency; the cap is ours.
- Which pre-call source and fields, and is there consent to show them to reps? The wizard through
pd_ct_idis recommended; the sample calls lack the key. - Multi-leg amounts and pairs (the Canada leg, under the minimum): an M2 policy change or pulled into M1b? Recommended M2, because it changes the chip templates and the minimum rule together.
- Is the 3-word "Sure. Well, I" scoring as
client_acceptsa Jev judging problem (the noul's criteria) or purely a stitching problem? The guard fixes both; the criteria could still be tightened in the bank (a paid re-ask). - Does an employer or HR letter count as proof of address for the activating partner? Needed before the tax-residency line can be approved.
- The rep's line 55 "we don't share it with anyone else": approved wording or a caution?
- The served-country list per partner, for the jurisdiction gate and the serviceability fact: until it exists the gate is inert and the fact is satisfied by a known residency.
- M3 transport: does Aircall expose a live media stream or separate legs, and does its real-time transcription webhook (named by a reviewer, unchecked) carry both parties with usable timing? A one-day spike against a real Aircall call, before M3 is planned. And is the paid Workers plan acceptable for nova-3 streaming, at about $0.09 per ten-minute call on top of the engine?
- Should nova-3's real word times be kept for uploads now (4-6 h) so uploaded calls get exact sentence splits before M3, and the gap figures of §3.2 can be re-measured on real times?
- A correction mid-call versus a second transfer: one amount slot cannot tell them apart (the chip reads "updated"; the second leg is lost). Multi-leg slots (M2) fix the second; the first needs a value history, also M2. Until then 111b keeps the minimum item open once a second amount is heard.
- Do reps need a dismiss or defer control on a suggestion? A question for the first team trial.
- Hold music, talk-over and non-English stretches have no defined behaviour; the M2 corpus shows how often they occur. On Aircall transcripts talk-over lands as interleaved short lines (lines 55-60 here), which the 4-word guard and the run-end rule absorb.
LIVE_POINTS_MAX: three or two? Decided on the shotgun board before COPILOT-107.
9. Where the investigations disagreed, and what was kept
| Point | Figures | Kept | Why |
|---|---|---|---|
| Cost per call today | $0.031 (96 × median 7.8k tokens × $0.042/M) vs $0.034 (eval cost_by_call) |
$0.034 | measured on actual token counts (mean 8,489), not the median |
| Split rule | every 25 words (152 points, $0.050) vs sentence end after 6 words and 5 s, forced at 12 s (133 points, $0.047) | the time rule, implemented inside stitch() as the 25-word proposal was |
bounded worst case with the fewest extra decisions, under the gate before the pre-judge questions |
| How far Stevan played | 56 decisions to line 69 vs 86 decisions to line 107 | both | queried at different times; he played on after a pause; his remarks concern the first seven minutes |
| Time the first-transfer-date point was on screen | about 19 s (raw decision times) vs 23 s (presenter simulation) | 23 s | the simulation runs the real presenter.ts; either way it was readable and the fault is the uncovered withdrawal |
| What text vanished | the caution fade or covered-point fold (a guess from the rules) vs the amount point at 1.0 s (measured) | the measured point | no caution has ever fired on the sample calls |
| Presenter change moment after splitting | "must become the end of a speaker run" vs "keep turn end, or allow sentence end" | run end for the card, sentence end for points, behind a flag | matches G's rule 1 and Stevan's ask at once; the flag makes 1b one line |
| Show each line at its start and cap gaps at 6 s, like the reference | proposed as a "more live" option | rejected | shows text before it was said; cannot carry to live audio |
| Where the jurisdiction concern should be fixed | engine reopen and clearing guard vs a code gate on known-and-served residency vs a view-only collapse | the first two; the gate once the partners' served list exists (inert until then); the collapse only as a fallback | the view fix would hide two engine episodes rather than prevent them |
10. Changes after the 2026-09-27 reviews
Two independent reviews (one against the code and the live data, one against the design) were applied the same day. What changed, and where:
- A forced re-import deletes every derived row of a call (
calls-import.ts:24), so the fixtures 107 and 117 need, Stevan's session and the marks and labels are exported first (COPILOT-104a) and re-inserted remapped (104b); labels map to the part holding the quote's end; the demo script is regenerated. The Worker ingest moves to v2 with it (ingest.ts:381,speaker-map.ts:308,build.ts:120). - The cost gate is $0.05 in
eval-l6.ts:25, not $0.055; the pre-judge questions cost about 1,200 tokens per client-part request (like-for-like §3.4), not 300, so the strong calls land at about $0.050-0.053; decision 16 and COPILOT-106 own the gate. Live transcription is a separate $0.09 per ten-minute call. - R6 pre-judges the situation Choice as well as the fits (
shownSituationIdneeds both); the pre-judge questions get their own drop step ahead offact_values. R4 holds a rep-turn point until the next rep-turn decision (line 10 is a client line and carries no tick). R5 applies only in the [0.3, 0.6) band; a clear miss leaves at once (decision 17). The point floor yields to a cover, a hard drop and a fourth point. - The clearing guard is a word count only (no run-end test: the engine cannot see the next turn); measured on the recorded answers, this call yields one row raised twice, no reopen, so 117's test says so and the reopen path uses a synthetic transcript. The reopen window is in call seconds.
- The served-country list starts empty, so the jurisdiction gate is inert until the partners' list arrives and the "can't serve" exit is kept; 113, 117 and 118 no longer contradict each other on the episode count.
- Seeded facts on the sample call carry source
demoand the label "Seeded for the demo", never "From sign-up"; the wizard has no residency field (KYC address); 3485591407 has a realpd_ct_idfor the M2 join. The 112 probe uses the policy's label ids (salary_or_pension,proof_of_address_outstanding) and supplies the amount thatunderstand_the_transferneeds. - The plan grammar names only signals and slots that exist;
bank_countryis a bank change of its own (110b);confirm_funding_accountis a must-say with anappliesrule;agree_next_stepkeeps the client-only lock; 110 and 111 are split into one-commit stories. - Proposed wording stays outside
policy/src(the loader rejects drafts and[VERIFY]); 110c applies Stevan's reply; the [VERIFY] line is never covered by "approve all"; the tax-residency point uses{country}. - Option B of decision 11 (the FEEDBACK's decision 3, COPILOT-109) is restated honestly (no approved flat lines exist to restore; it loosens the like-for-like rule) and the FEEDBACK attributes the gate to the like-for-like restructure, not the rate one. The "
{country}copy bug" was not one (render.ts:87already renders the display name) and is gone. - Interpolated word times are an animation, not a measurement (a pause inside a 41 s fragment shifts words by the pause); the live mapping no longer equates a Deepgram final with a part; the reconnect estimate is 5, not 6; 118 measures spoken-to-visible and sentence-end-to-feedback end to end, warms the cache before the 1x walk, and counts withdrawn points; judgement items are not scored anywhere; a second amount keeps the minimum item open; a shotgun board precedes 107's and 114's screenshots.
Rejected: "word count is not resolution evidence" (the Jev client_accepts signal is still required; the guard only filters which lines may carry it), "the 12 s floor keeps contradicted advice" in its strong form (the engine has no contradiction signal beyond the fit; a fit under 0.3 now removes the point at once), and "a safety concern waits out the dwell" (an inserted client item never waits for the dwell, presenter.ts:164-170; a must-say due on a client signal is now treated the same).