Status: design of record, 2026-07-28. Vision: 00-VISION.md · Spaces & profile: 17-SPACES-AND-PROFILE.md · Engine: 12-ENGINE-PLAN.md.
Starting a session asked the user two questions that were the same question.
Mode — Interview 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 kind — job / 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, job↔interview,
subject↔tutor — 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.
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 separately — Listen 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.
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:
job refuses to start without one.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.
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.
sessions.activityMigration 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.
predictFollowup, the
meeting report strategy, and companion’s cost meter are untouched.subject runs meeting today. When tutor ships, one line in
ACTIVITIES moves it over and the flag gate lets it appear.profiles, still deferred for
the reasons in 17 §3.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.
needsSpaceThe 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.