BrainCue

18 · Activities — one question, not two

Status: design of record, 2026-07-28. Vision: 00-VISION.md · Spaces & profile: 17-SPACES-AND-PROFILE.md · Engine: 12-ENGINE-PLAN.md.

1. The conflict

Starting a session asked the user two questions that were the same question.

ModeInterview Copilot / Practice / Interviewer Assist / Meeting Copilot / Tutor / Companion. This list is a catalog of product features, written when the app was Interview Copilot and everything else was roadmap. It shows: one entry that never started a session (Practice navigated away to a drill page) and two that did not exist (Tutor, Interviewer Assist, rendered disabled with “Coming soon”).

Space kindjob / meeting / project / subject / personal / game / custom. Added in 17 to answer what a conversation is about.

They collided item for item — meeting was in both, jobinterview, subjecttutor — and their defaults contradicted each other: the start modal opened on Interview while a new Space defaulted to Meeting. Nothing reconciled them, so a Meeting Space could be started in Interview mode and be told, by the answer prompt, that the user was a candidate being assessed.

2. Why “mode” was never a real choice

A ModeDefinition is configuration over one engine, and reading all three shows what it actually configures:

Mode Hears Questions from Speaks when Frames the user as
interview the call (system audio) and your microphone the call a question is detected a candidate being assessed
meeting the call (system audio) and your microphone the call the ambient policy fires themselves, in a conversation
companion you (microphone) you the interjection policy fires themselves, with a persona

Every column is a dial the start flow already showed separatelyListen to, Presence, and the answer framing from 16 §9. So the mode picker asked the user to choose a bundle they could also see unbundled. That is precisely why it read as a conflict.

3. The resolution

One list, and it is the activity list. The user answers “What’s this call?” — and nothing else about what BrainCue will be. The engine mode is derived (shared/activities.ts), so what was shown and what runs cannot drift.

Activity Runs listensTo Needs a résumé
Meeting or call meeting system no
Project discussion meeting system no
Interview interview system yes
Study or tutoring meeting system no
Personal meeting system no
Game companion mic no
Just me companion mic no
Something else meeting system no

ModeDefinition stays exactly as it was — it is the right internal architecture. It simply stops being a question.

listensTo means “which streams”, not “which one” (2026-09-07)

There used to be a Listen to control on the start modal, defaulted by the activity and flippable: system audio or the microphone. That was the wrong question — someone on a call does not want to choose between hearing the call and hearing themselves — so the control is gone and a session hears both. ActivityConfig.listensTo is kept, with a new meaning, resolved by capturePlan() in the same file and used by the renderer and the engine alike:

listensTo Streams captured Questions come from
'system' — a call the call’s system audio and your microphone the call. Your own words are transcribed and kept (you / candidate), shown in the transcript and remembered for the next answer, but never run the trigger.
'mic' — solo (Game, Just me) your microphone you — the mic is the trigger source, exactly as before.

The start modal prints this as a passive line under the activity (“Hears the call (system audio) and your microphone. Questions come from the call.”); Home’s chip says Hears: call + mic / Hears: microphone. If the system-audio picker is refused, the session goes on with the microphone alone and says so (a non-fatal notice); if the microphone is denied, with the call alone; with neither it does not start. The old audio.source preference is kept in the schema for old settings files but nothing reads or writes it.

Four consequences worth naming:

What the user still chooses

Presence (and, for companion activities, posture + budget). That genuinely varies within an activity — the same standup can want silence one week and help the next — so it stays a question. Everything else the mode used to bundle is now printed on the choice (ActivityConfig.does) as a consequence rather than asked as a second one.

4. The résumé gate

startBlocker refused to start anything without a parsed résumé. You could not sit in on your own standup without first uploading a CV — the loudest remaining way the app insisted it was an interview tool.

The gate is now per-activity, and exactly one activity sets it: Interview answers as the candidate, from their history, so it genuinely cannot work without one. A meeting needs nothing but a profile to attach to.

5. sessions.activity

Migration 0014, additive, nullable.

The mode alone cannot stand in for it. Several activities share one mode — a project call and a standup both run meeting — so storing only the derived mode erases what the user actually said. And a session started without a Space has no kind recorded anywhere else at all.

Null means “we don’t know”: v1 rows, and rehearsals, which have no activity. Surfaces fall back to the mode-shaped label rather than guessing.

fkRebuild.ts’s SESSIONS_DDL had to learn the column too — the rot guard in fkRebuild.test.ts caught the omission on the first run, which is what it was written for after sessions.mode was silently dropped by the same mechanism.

6. What this does NOT change

7. Not in this milestone

8 · The Space list is filtered by the activity (2026-07-28)

The start modal used to list every Space on the profile, and picking one rewrote the activity underneath (a Space is a saved activity, so its kind wins). Two problems compounded: the list was a directory of everything the profile had ever set up, most of it irrelevant to the call about to start; and answering the first question could silently un-answer it.

spacesFor(spaces, activity) (in startFlow.ts, so it is testable on its own) now offers only the Spaces of the chosen activity. Changing the activity drops a selection that no longer matches, because a selection you cannot see is the one that surprises you afterwards. activityOf(kind) supplies the comparison and agrees with activity(kind) on the fallback, so a v1 Space with no kind appears under Something else rather than vanishing from every list.

The effect that derives the activity from the Space stays, and now only ever fires for a Space arriving as initialSpaceId from the Library — one picked in the modal already matches by construction.

Creating one without leaving. A “New Space” button beside the picker opens JobFormModal with initialKind set to the activity already chosen, so the same question is not asked twice and cannot be answered differently. It replaces the start modal rather than stacking on it: the start form’s state lives in StartSessionModal, so it survives untouched and returns with the new Space selected. Closing is the sub-form’s own call — it stays open to report a link it could not read.

9 · needsSpace

The second per-activity gate on ActivityConfig, read by startBlocker exactly like needsResume, and true for exactly one activity — see 16 §15 for why the interview is the one that cannot start without a Space, and for the rule that makes a Space the only place a conversation is kept.