feat(meeting): listen-only detection, prompt re-offers, launch-at-login default, missed-call nudge, funnel telemetry#1355
Conversation
Two capture-rate fixes in the detected-meeting prompt pipeline (docs/auto-call-detection-spec.md, Phase 4): 1. Listen-only calls now prompt. MicActivityMonitor also reads kAudioProcessPropertyIsRunningOutput and emits which native conferencing apps (Zoom/Teams/Webex/FaceTime) are playing sustained call audio — the join-muted/camera-off call that the mic and camera signals both miss. Output is scoped to native conferencing families (browser/media playback never counts), uses a longer 10s sustain so notification dings stay quiet, and tiers below the mic and above the camera in callSignals with the same mic:<provider> candidate id so one call still raises exactly one prompt. 2. An ignored prompt is no longer an explicit "no". Countdown expiry used to take the same dismiss path as clicking ×, suppressing the provider for 30+ minutes — one missed 30-second pill lost the whole meeting. Expiry now routes through onPromptExpired → MeetingPromptDetector.expire, a short candidate-level re-offer (3 min, no provider suppression) capped at 2 re-offers before falling back to the normal dismissal. Expiry telemetry reuses meeting_prompt_dismissed with backoff kind expired_reoffer and workflow_abandoned reason expired so dashboards can split "said no" from "never saw it". Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019V6c3NySrQnEXj19rARgP4
…e prompts Three more capture-funnel layers (docs/auto-call-detection-spec.md, Phase 4 follow-on): 1. Launch-at-login defaults on, once per install, only after onboarding completes, never over an explicit choice, and never re-applied after the user removes the login item in System Settings. The detection stack is dead while the app is closed. 2. Missed-call awareness nudge: when a detected call session (folded from the mic/output/camera signals) ends >=10min with no recording and no explicit decline, the overlay shows a one-line 'That Zoom call wasn't recorded' nudge with Got It / Don't show again. Rate-limited (4h cooldown), preference-gated (default on, Settings General toggle), never offers to record a call that is over. 3. Ad-hoc call prompts now keep a 60s countdown (calendar reminders stay 30s): a live call's moment doesn't age out, and combined with expiry re-offers an unrecorded call sees ~3min of cumulative prompt time instead of 30s. Analytics: new allowlisted meeting_missed_call_nudge event (action/duration_bucket/provider), taxonomy check green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019V6c3NySrQnEXj19rARgP4
Make prompt tuning measurable instead of guessed: 1. meeting_detected_call_ended — the funnel denominator. Every detected call >=1min emits one coarse summary at its end: duration_bucket, was_recorded, prompt_outcome (recorded/declined/ignored/no_prompt), signal_kinds (which sensors saw the call: mic/speaker/camera combos), provider. Capture rate becomes 'was_recorded share of long calls', and the outcome split separates UX misses (ignored) from intent (declined) from detection gaps (no_prompt). 2. Decision-time signal snapshot on all meeting_prompt_* events: mic_signal / speaker_signal / camera_signal booleans, so accepts and 'Not now's can be sliced by the evidence active at that moment. 3. dismiss_streak_bucket (1/2/3_plus, reset on accepted recording) on meeting_prompt_dismissed — the 'keeps hitting not now' cohort that should get less aggressive prompting, not more. All properties stay bucketed enums/booleans; no raw durations, bundle IDs, or device names. Allowlists updated (analytics-events.psv, analytics-reviewed-properties.psv, privacy doc); taxonomy check green. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019V6c3NySrQnEXj19rARgP4
The taxonomy guard forbids property names containing 'speaker' (reserved for the sensitive speaker-name rules), and the runtime sanitizer drops such keys before send — so speaker_signal both failed AnalyticsEventPolicyTests and would never have reached PostHog. Rename the property to output_signal and align the coarse values (output_active, native_output, signal kind 'output') so the output sensor keeps one name across the taxonomy. Adds a regression assertion that no speaker-named key is emitted. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019V6c3NySrQnEXj19rARgP4
|
Maestro hold review: HOLD at head 14a861f. Still draft and release-sensitive: listen-only output detection, prompt expiry re-offers, launch-at-login default enable, missed-call nudge, 60s prompts, and capture-funnel telemetry. Code review found the launch-at-login default is a consent/release decision because applyDefaultEnableIfNeeded runs after onboarding completion unless an explicit/default marker exists; do not merge this silently. Smallest clear path: decide default-on vs opt-in policy, run integration smoke, normalize-analytics-taxonomy --check, live muted/camera-off Zoom or Teams proof for output detection, verify ignored prompt re-offers and cap, verify explicit dismiss suppresses nudges, verify 10+ minute missed-call nudge and opt-out persistence, and confirm telemetry payloads are enum/bucket only. |
|
Status against the hold checklist, item by item (head Decided / done
Covered by fast tests (ran green in CI), still worth a live pass
Needs a Mac (cannot run in this Linux session)
Note: the PR was flipped to ready-for-review after this hold was posted. The remaining gate is the live verification list above. Generated by Claude Code |
Why
Audit of the "system thinks you're in a meeting → user taps record" pipeline found the leaks that lose whole meetings, plus two structural gaps: nothing works if the app isn't running, and there was no way to measure where the funnel leaks.
Product Impact
meetingsactivationWhat changed
Commit 1 — detection + prompt fixes
MicActivityMonitoralso readskAudioProcessPropertyIsRunningOutput(metadata-only, TCC-free) and emits which native conferencing apps (Zoom/Teams/Webex/FaceTime families only — browser/media output never counts) are playing sustained call audio, with a longer 10s sustain so dings stay quiet. New.audioOutputreason; tiers mic → output → camera incallSignals(detector + synthetic evaluator in lockstep); samemic:<provider>candidate id so one call = one prompt.onPromptExpired→detector.expire()— a 3-minute candidate-level re-offer (no provider suppression), capped at 2 re-offers, then normal dismissal. Explicit × unchanged.Commit 2 — always running, longer prompts, missed-call awareness
LaunchAtLoginController.applyDefaultEnableIfNeeded+ applied-marker in preferences).MeetingPromptHeuristics.promptTimeoutSeconds.Commit 3 — capture-funnel telemetry (all coarse, allowlisted)
meeting_detected_call_ended: one event per detected call ≥1 min —duration_bucket,was_recorded,prompt_outcome(recorded/declined/ignored/no_prompt),signal_kinds(mic/output/camera combos),provider. The capture-rate denominator.mic_signal/output_signal/camera_signalbooleans) on allmeeting_prompt_*events.dismiss_streak_bucket(1/2/3_plus, reset on accept) on dismissals.analytics-events.psv,analytics-reviewed-properties.psv, privacy doc);normalize-analytics-taxonomy.py --checkgreen.Commit 4 — CI fix
speaker_signal→output_signal(and aligned coarse values to "output"): the taxonomy guard forbids property names containing "speaker" (reserved for the sensitive speaker-name rules) and the runtime sanitizer drops such keys — caught byAnalyticsEventPolicyTestson the macOS runner. Added a regression assertion.Docs:
docs/auto-call-detection-spec.mdPhase 4 (+ follow-on + telemetry sections),Sources/Meeting/CLAUDE.md,Sources/Support/CLAUDE.md.Deliberately not built: default auto-record after a countdown. It stays the documented opt-in tier (short ~8s countdown, never-silent start, default OFF) per
docs/MEETING_CAPTURE_PROMPTING.md; the newprompt_outcomesplit (ignored vs declined) is the data that should decide whether that tier gets built.How I checked it
scripts/dev/agent-preflight.shpython3 scripts/ops/normalize-analytics-taxonomy.py --checkbuild-and-test(macOS runner) — first run caught thespeaker_signaltaxonomy violation (11478 tests, 1 failed); fixed in commit 4, re-run pendingbash run-integration-smoke.sh(Sources/Meeting touched) — needs a local Mac runswift test— not applicable (no core seam changes)Honest caveat: this session ran in a Linux container with no Swift toolchain — build + fast tests run via CI's macOS
build-and-testjob, which I'm watching and fixing. Fast-test coverage added for: monitor output attribution, heuristics (output mapping, expiry/timeout/nudge policies, funnel buckets/outcomes/streaks), synthetic-prompt tiering/gates, detector (listen-only prompt, de-dupe, expire re-offer/cap, short-call no-nudge, dismiss streaks), telemetry property shapes (incl. the no-speaker-key regression), and launch-at-login default policy.Risk Review
.agent-review/visuals/evidence — nudge reuses the existing prompt panel; no new layout (screenshot on a Mac would still be nice)Notes
Dashboard queries this unlocks: capture rate =
was_recordedshare ofmeeting_detected_call_endedwithduration_bucket ≥ 10_to_20m; leak attribution =prompt_outcomesplit; prompt-conversion by evidence =meeting_prompt_record_selectedsliced bymic_signal/output_signal/camera_signal; nag-sensitivity cohort =dismiss_streak_bucket = 3_plus. Ifignoreddominatesdeclined, the aggressive opt-in auto-record tier earns its switch.Agent handoff
COORD_DONE: BRIEF | PR URL above | output signal + expiry re-offer + launch-at-login default + missed-call nudge + 60s live prompts + funnel telemetry + speaker_signal CI fix | none | decide nudge thresholds (10min/4h) + whether to build opt-in auto-record tier | agent-preflight + taxonomy check + CI macOS build-and-test (watching) | wait for CI green, live-verify muted join + nudge, then review🤖 Generated with Claude Code
https://claude.ai/code/session_019V6c3NySrQnEXj19rARgP4