Skip to content

feat(meeting): listen-only detection, prompt re-offers, launch-at-login default, missed-call nudge, funnel telemetry#1355

Merged
r3dbars merged 4 commits into
mainfrom
claude/meeting-recording-auto-launch-t0oc5g
Jul 3, 2026
Merged

feat(meeting): listen-only detection, prompt re-offers, launch-at-login default, missed-call nudge, funnel telemetry#1355
r3dbars merged 4 commits into
mainfrom
claude/meeting-recording-auto-launch-t0oc5g

Conversation

@r3dbars

@r3dbars r3dbars commented Jul 2, 2026

Copy link
Copy Markdown
Owner

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.

  1. An ignored prompt was treated as an explicit "no". Countdown expiry took the same path as clicking ×, suppressing the provider for 30+ minutes (Teams: 2h). One missed 30-second pill = meeting never recorded.
  2. Listen-only calls were invisible. Every ad-hoc sensor required this Mac to be sending something (mic held / camera on). A muted, camera-off join with no calendar invite produced zero signals.
  3. The app can't detect anything while it isn't running, and launch-at-login was a buried Settings toggle.
  4. Users never learned a meeting was missed, and we couldn't measure capture rate against calls that actually happened.

Product Impact

  • Affects: meetings
  • Lane: activation
  • Why this matters: raises the fraction of real meetings that get a working prompt, a second chance, and a recording — and makes the funnel measurable so future aggressiveness decisions (e.g. an opt-in auto-record tier) are data-driven.

What changed

Commit 1 — detection + prompt fixes

  • Audio-output call signal: MicActivityMonitor also reads kAudioProcessPropertyIsRunningOutput (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 .audioOutput reason; tiers mic → output → camera in callSignals (detector + synthetic evaluator in lockstep); same mic:<provider> candidate id so one call = one prompt.
  • Unattended-prompt expiry path: countdown expiry now routes through onPromptExpireddetector.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

  • Launch-at-login defaults on: once per install, only after onboarding completes, never over an explicit choice, never re-applied after a System Settings removal (LaunchAtLoginController.applyDefaultEnableIfNeeded + applied-marker in preferences).
  • Ad-hoc call prompts keep a 60s countdown (calendar reminders stay 30s) via MeetingPromptHeuristics.promptTimeoutSeconds.
  • Missed-call nudge: when a detected call session ends ≥10 min with no recording and no explicit decline, the overlay shows "That Zoom call wasn't recorded · About 42 minutes" with Got It / Don't show again. 4h cooldown, preference-gated (default on, Settings General toggle), never offers to record a finished call.

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.
  • Decision-time signal snapshot (mic_signal/output_signal/camera_signal booleans) on all meeting_prompt_* events.
  • dismiss_streak_bucket (1/2/3_plus, reset on accept) on dismissals.
  • Allowlists updated (analytics-events.psv, analytics-reviewed-properties.psv, privacy doc); normalize-analytics-taxonomy.py --check green.

Commit 4 — CI fix

  • Renamed speaker_signaloutput_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 by AnalyticsEventPolicyTests on the macOS runner. Added a regression assertion.

Docs: docs/auto-call-detection-spec.md Phase 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 new prompt_outcome split (ignored vs declined) is the data that should decide whether that tier gets built.

How I checked it

  • scripts/dev/agent-preflight.sh
  • python3 scripts/ops/normalize-analytics-taxonomy.py --check
  • CI build-and-test (macOS runner) — first run caught the speaker_signal taxonomy violation (11478 tests, 1 failed); fixed in commit 4, re-run pending
  • bash run-integration-smoke.sh (Sources/Meeting touched) — needs a local Mac run
  • swift test — not applicable (no core seam changes)
  • Manual check: live listen-only Zoom/Teams join (muted, camera off) fires the output signal; ignored prompt re-offers at ~3 min; short unrecorded call raises no nudge but a 10+ min one does; fresh onboarding registers the login item once

Honest caveat: this session ran in a Linux container with no Swift toolchain — build + fast tests run via CI's macOS build-and-test job, 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

  • Privacy / local-first behavior reviewed — output read is metadata-only (no audio tapped, no new TCC/entitlement); all new analytics properties are bucketed enums/booleans, reviewed via the allowlist pipeline; no raw durations, bundle IDs, device names, or titles leave the device
  • Storage path or migration impact reviewed — none
  • Public-facing copy stays concrete (Settings info popovers + nudge one-liner)
  • Release/update impact reviewed — launch-at-login default applies once to existing installs on update, with the standard macOS "added to Login Items" notice as disclosure and the Settings toggle to revert; flagged for release notes
  • Agent PR stays draft until human review
  • UI changes include sanitized .agent-review/visuals/ evidence — nudge reuses the existing prompt panel; no new layout (screenshot on a Mac would still be nice)
  • No private transcripts, audio, tokens, personal paths, or customer data are included

Notes

Dashboard queries this unlocks: capture rate = was_recorded share of meeting_detected_call_ended with duration_bucket ≥ 10_to_20m; leak attribution = prompt_outcome split; prompt-conversion by evidence = meeting_prompt_record_selected sliced by mic_signal/output_signal/camera_signal; nag-sensitivity cohort = dismiss_streak_bucket = 3_plus. If ignored dominates declined, 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

claude added 3 commits July 2, 2026 20:42
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
@r3dbars r3dbars changed the title feat(meeting): listen-only call detection + unattended-prompt re-offer feat(meeting): listen-only detection, prompt re-offers, launch-at-login default, missed-call nudge, funnel telemetry Jul 2, 2026
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
@r3dbars

r3dbars commented Jul 2, 2026

Copy link
Copy Markdown
Owner Author

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.

@r3dbars r3dbars marked this pull request as ready for review July 3, 2026 01:34

r3dbars commented Jul 3, 2026

Copy link
Copy Markdown
Owner Author

Status against the hold checklist, item by item (head 14a861f, CI now green — 11,478 fast tests passed on the macOS runner, including the taxonomy guard that caught and killed speaker_signal in the earlier run):

Decided / done

  • Launch-at-login default-on vs opt-in: default-on was an explicit direction from the owner in the driving session, not an agent choice. Safeguards in code: applies only after onboarding completes, at most once per install (applied-marker), never overrides an explicit Settings choice, a System Settings removal sticks (no re-registration), and macOS shows its standard "added to Login Items" notice. If you'd rather make it an onboarding opt-in prompt instead, it's a one-call-site change (finishOnboarding + the app-init backfill) — say the word.
  • normalize-analytics-taxonomy.py --check — green (run in-session and enforced again by CI).
  • Telemetry payloads enum/bucket only — enforced three ways: the psv allowlist, AnalyticsEventPolicyTests forbidden-fragment/reviewed-property guards (which is exactly what failed the first CI run), and new MeetingPromptTelemetryTests asserting the property shapes, including a regression test that no speaker-named key is ever emitted.

Covered by fast tests (ran green in CI), still worth a live pass

  • Ignored-prompt re-offer + cap: MeetingPromptDetectorTests (expire returns expired_reoffer with the 3-min window, falls back to runtime_default_fallback past 2 re-offers, re-offer is candidate-level only).
  • Explicit dismiss suppresses nudges: MissedCallNudgePolicy tests (userDeclined → never nudge) + detector marks the session declined on dismiss/snooze.
  • 10+ min threshold, 4h cooldown, recorded-call silence: MissedCallNudgePolicy tests; short-call no-nudge covered end-to-end in detector tests.

Needs a Mac (cannot run in this Linux session)

  • bash run-integration-smoke.sh
  • Live muted/camera-off Zoom or Teams join → "Zoom call detected" via the output signal (~15s worst case: 5s poll + 10s sustain)
  • Live re-offer timing and the missed-call nudge UI + "Don't show again" persistence through Settings

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

@r3dbars r3dbars merged commit c5c1fbc into main Jul 3, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants