meeting-tab.md141.9 KBView on GitHub
# The Meetings Tab — the meeting-prep agent's workspace, inside the deal

## 1) Introduction — goal, present state, future state

We want a **Meetings** tab in the conversation view that *is* the meeting-prep agent — the same workspace `/agents/:agentId` already renders, scoped to this deal instead of to the user, with the same four tabs and the same chrome — topped by a strip that names the upcoming meeting, says plainly whether prep has run for it yet (and if not, why not, with a button to run it now), and carries a row of actions appropriate to where you are in the meeting's life. Inside that workspace the agent's Output leads with a full note-taking editor for the meeting you are in, sitting above the prep brief, and the notes file is *the agent's own file* — it lives in the agent's namespace, so it appears in the agent's file tree, counts as its output, and is the thing the post-meeting pipeline triages. Beneath the brief sit two histories: **Past Meeting Preps**, a row of buttons over the agent's `archives/` folder, and **Previous meetings**, a paged log of every meeting this deal has had, each row reaching its own notes and its own archived prep. Once that tab holds everything, the meeting-prep agent stops being listed twice — it is dropped from the conversation's agent rows and from the Files tab's Agents folder, because the Meetings tab is now its home in a deal. Today the pieces exist but nothing connects them: the workspace at [AgentView.tsx:44](apps/mail/modules/agents/components/AgentView.tsx) is hard-wired to `user/agent-{agentId}` through [agent-paths.ts:13](apps/mail/modules/agents/utils/agent-paths.ts) and [outputs.ts:37](apps/server/src/services/agent-workspace/outputs.ts) so it can only ever show the user-scoped copy of an agent, the deal's prep brief is reachable only as a `__meeting_prep__` sentinel deep-link into the **Files** tab ([resolveOpenDoc.ts:139](apps/mail/modules/conversations/components/files/resolveOpenDoc.ts)), before-meeting runs are scheduled with a dedup key that already encodes the exact Google event ([calendar-events.ts:471](apps/server/src/services/crm/calendar-events.ts)) but nothing ever reads it back to answer "has prep run for Thursday's call", the meeting actions live on calendar surfaces the conversation view never mounts ([CalendarEventContextMenu.tsx:39](apps/mail/modules/calendar/components/CalendarEventContextMenu.tsx), [EventDetailsPopover.tsx:1137](apps/mail/modules/calendar/components/EventDetailsPopover.tsx)), and there is no note-taking surface bound to a meeting at all — so the post-event pipeline ([handleExecuteMeeting.ts:94](apps/server/src/mastra/routeHandlers/event-execution/handleExecuteMeeting.ts)) is structurally blind to anything the human typed during the call. The change is therefore: make the agent workspace scope-aware rather than user-only, mount it as the Meetings tab for the meeting-prep agent, add a prep-status read model over the dedup key that already exists, put a `meeting_notes` document inside the agent's own namespace with templates and structured markers, surface the archives and the meeting history as first-class sections, assemble the action row from handlers that already exist, feed the notes into the post-event execution, and then relocate the agent out of the two deal surfaces that would otherwise show it a second time.

## 2) Present state

### 2.1 Architecture diagram

```text
  ┌──────────────────── CONVERSATION VIEW ──────────────────┐   ┌─────────────────── /agents/:agentId ────────────────────┐
  │ ConversationBodyLayout [:22]                            │   │ AgentView [AgentView.tsx:44]                  │
  │  ConversationTabs [:25]  ← CONVERSATION_TAB_KEYS [:38]  │   │  "Structurally this IS the conversation view, and        │
  │   Overview │ Timeline │ Inbox │ Files │ CRM │ Directory │   │   deliberately so" — same gutter, same 75ch column,      │
  │  ConversationTabBody [:17] switch(tab)                  │   │   same px-3 pt-1.5 / pt-2 paddings.                     │
  │        │                          │                     │   │  AgentBackGutter · AgentHeader · AgentTabs [:1]          │
  │        ▼                          ▼                     │   │   Output │ Config │ Memory │ Previous Runs             │
  │  StrategicOverviewTab        FilesTab [:36]             │   │        │                                                │
  │                                  │ conversationOpenFile │   │        ├─ AgentOutputTab [:91]                          │
  │                                  ▼                      │   │        │    AgentFileBrowser [:42]                      │
  │                           resolveOpenDoc [:139]         │   │        │      rootPath = agentNamespacePath(agentId)    │
  │                             '__meeting_prep__'          │   │        │      ── agent-paths.ts:13 ────────────────┐    │
  │                              ├ agentDocs.find(rawName   │   │        │      `user/agent-${agentId}`              │    │
  │                              │   === 'meeting-prep')    │   │        │      "An agent's namespace is USER-scoped"│    │
  │                              └ nonAgentDocs.find(       │   │        │                                           │    │
  │                                  type='meeting_prep')   │   │        ├─ AgentConfigPage  (sources/connections/   │    │
  │                                  │                      │   │        │                    instructions)          │    │
  │                                  ▼                      │   │        ├─ AgentMemoryTab                           │    │
  │                           OverviewDocTab [:157]         │   │        └─ AgentRunsTab                             │    │
  │                                  │                      │   └────────────────────────────────────────────────────┼────┘
  │                                  ▼                      │                                                        │
  │                           Document [:153] ── Y.js       │        server: getAgentOutputs [outputs.ts:37]          │
  └─────────────────────────────────────────────────────────┘          namespace = agentNamespacePath({type:'user'})  │
        ▲                                                              ── the SAME hardcode, on the server side ──────┘
        │ handleOpenMeetingPrepFile [use-calendar-canvas-actions.ts:15]
        │   openConversation({section:'files'}) + setConversationOpenFile(sentinel)
        │   ✗ the clicked EVENT is dropped — only conversationId survives
        │
  ┌─────┴──────────────────┐   ┌────────────────────────────────────────────┐
  │ AgendaMeetings [:66]   │   │ CALENDAR SURFACES — never mounted in a deal │
  │ AgendaEventBlock [:46] │   │  CalendarEventContextMenu [:39]             │
  │  → no-show button only │   │    scheduleFollowUp · sendNoShowPrompt ·    │
  └────────────────────────┘   │    sendReschedulePrompt · delete            │
                               │  EventDetailsPopover [:1137]                │
                               │    handleRsvpChange [:1850] · handleSave-   │
                               │    Changes [:1543] · handleAddGoogleMeet    │
                               │    [:1951] · handleAddAttendee [:1975] ·    │
                               │    handleDelete [:2104] · handleJoinMeet    │
                               │    [:2131] · handleCreateNextMeeting [:2214]│
                               └────────────────────────────────────────────┘

  ── BEFORE the meeting: the link to the event that nothing reads back ────────────────
  calendar sync ──► scheduleBeforeMeetingAutomations [calendar-events.ts:278]
        │  reads playbook_manifest.beforeMeetingConfigs  [{ minutes, stage, orgAopId }]
        │  skips stage-scoped configs whose stage ≠ conversation.status
        │  triggerAt = startTime − minutes
        ▼
  INSERT agent_executions {
      status 'pending', source 'playbook-before-meeting',
      scheduled_for = triggerAt, conversation_id, agent_id,
      dedupe_external_id = `{googleEventId}:before-meeting:playbook:{aopId}:{stage}:{minutes}`
                            └──────────────┬──────────────┘
                                           └── THE structural link from a run to ONE meeting.
                                               Written at :471, matched at :426, and read
                                               back by nothing that renders.
  }
        │  queue fires at scheduled_for
        ▼
  handleExecuteScheduledExecution [:36] ──► verifyAndHealBeforeMeetingEvent [:1358]
        │   (re-derives the meeting from scheduledFor + minutes, ±30 min window)
        ▼
  meeting-prep subagent — MEETING_PREP_DEFAULT_BODY [meeting-prep.ts:186]
        writes ──► conversation/{convId}/agent-{agentId}/overview
        archives ─► conversation/{convId}/agent-{agentId}/archives/{date}
        notifies ─► Slack DM (headline + full brief as threadReplies)

  ── WHERE THE AGENT IS ALREADY DISPLAYED, TWICE ──────────────────────────────────────
  conversation agent rows          FilesTab "Agents" folder
    sortAgents [AgentRow.tsx:72]     buildAgentDocs [resolveOpenDoc.ts:76]
    HIDDEN_AGENT_NAMES [:65]           one AgentDoc per agent:
      post-event-task-executor           doc      = the overview
                                         files    = authored siblings
                                         archives = …/agent-{id}/archives/{date}
                                                    isAgentArchiveDoc [:53]
                                                    sorted DESC — "an archive path ends
                                                    in an ISO date, so a descending
                                                    string sort is a date sort"
                                       HIDDEN_AGENT_NAMES [:43] — NOT BUILT AT ALL:
                                         crm-updater · post-event · next-steps
                                       AgentFilesGroup [:22] renders the row +
                                         "Show archived" second collapsed row
                                            │
                                            ▼
                                       resolveOpenDoc [:139] '__meeting_prep__'
                                         READS agentDocs.find(rawName==='meeting-prep')
                                         ⚠ hiding meeting-prep inside buildAgentDocs
                                           would break every existing deep-link

  ── AFTER the meeting ────────────────────────────────────────────────────────────────
  handleExecuteMeeting [:94] ──► onEventAgentExecutionWorkflow [:1056]
        preExecutionSetupStep [:243] → orchestratorAgentStep [:302] → updateExecutionStep [:998]
              ├─ run-post-event-executor [orchestrator-dispatch-tools.ts:433]  drafts + userTasks
              └─ run-crm-updater                                              field extraction
        context = transcript + hydrated conversation + playbook.
        ✗ NOTHING the human typed during the call is an input. There is nowhere to type it.
```

### 2.2 Step-by-step walkthrough

1. **The conversation tab strip renders** — `ConversationTabs` at [ConversationTabs.tsx:25](apps/mail/modules/conversations/components/ConversationTabs.tsx)
   - Reads `conversationSection` from the Cedar store, maps over `CONVERSATION_TAB_KEYS` at [conversationsSlice.ts:38](apps/mail/modules/conversations/slice/conversationsSlice.ts), renders one `TabsTrigger` per key.
   - Data after this step:
     ```json
     { "keys": ["strategicOverview","timeline","inbox","files","crm","directory"],
       "value": "strategicOverview" }
     ```

2. **The body switches on that key** — `ConversationTabBody` at [ConversationTabBody.tsx:17](apps/mail/modules/conversations/components/ConversationTabBody.tsx)
   - A bare `switch (tab)` mounting six tab bodies. No meeting arm; a meeting has no tab of its own.

3. **The agent workspace exists, and is user-scoped by construction** — `AgentView` at [AgentView.tsx:44](apps/mail/modules/agents/components/AgentView.tsx)
   - Four tabs from `AGENT_TABS` at [types.ts:43](apps/mail/modules/agents/types.ts) (`output | config | memory | runs`), tab state in `?tab=`, rendered through `AgentTabs` at [AgentTabs.tsx:1](apps/mail/modules/agents/components/AgentTabs.tsx) — which is documented as having to be *indistinguishable* from `ConversationTabs`, "because they are the same control on two screens".
   - Its own doc comment states the layout contract: same back gutter, same `CONVERSATION_COLUMN` 75ch centering, same `px-3 pt-1.5` / `pt-2` paddings as `ConversationBodyLayout`, and "any spacing change here that is not also made in ConversationBodyLayout is a bug, not a variation."
   - Data it loads: `agent.get`, `agent.getOutputs`, `agent.getInvocationSources`, `agent.getConnections`, `agent.getRuns` — every one of them keyed on `{ agentId }` alone.

4. **The Output tab is a file tree over one hardcoded root** — `AgentOutputTab` at [AgentOutputTab.tsx:91](apps/mail/modules/agents/components/AgentOutputTab.tsx) → `AgentFileBrowser` at [AgentFileBrowser.tsx:42](apps/mail/modules/agents/components/AgentFileBrowser.tsx)
   - `rootPath = agentNamespacePath(agentId)` = `user/agent-{agentId}`, from [agent-paths.ts:13](apps/mail/modules/agents/utils/agent-paths.ts), whose comment is explicit: *"An agent's namespace is USER-scoped, not a scope of its own."*
   - `AgentFileBrowser` mounts `useFileTree` with `scope = { type: 'user', id: userId }` and renders `Document` / `TableDocumentView` / `AttachmentFileView` for whatever is selected — the deal Files tab's tree, rooted elsewhere.
   - Its sibling comment names the exact case this design is about: *"a meeting-prep doc belongs to the deal it preps, so it is counted and linked, never listed here as though the agent owned it."* Per-deal output is a **count** on this screen, deliberately.
   - Data after this step:
     ```json
     { "rootPath": "user/agent-b41c…",
       "owned": [ { "name": "overview", "path": "user/agent-b41c…/overview" } ],
       "inConversations": { "count": 37 } }
     ```

5. **The server read model has the same hardcode** — `getAgentOutputs` at [outputs.ts:37](apps/server/src/services/agent-workspace/outputs.ts)
   - `const namespace = agentNamespacePath({ type: 'user' }, agentId)`, then `LIKE '{namespace}/%'` with `eq(documents.userId, userId)`, excluding `memory/`.
   - `agentNamespacePath` at [convention-paths.ts:94](apps/server/src/services/documents/convention-paths.ts) *already takes an `AgentDocScope`* — `{ type:'conversation', conversationId } | { type:'user' }` — and already mints `conversation/{convId}/agent-{agentId}`. The scope parameter exists; both the workspace read and the client path helper simply pin it to `user`.

6. **The deal's prep brief is reached by a sentinel, not by the agent** — `handleOpenMeetingPrepFile` at [use-calendar-canvas-actions.ts:15](apps/mail/modules/agentCanvas/hooks/use-calendar-canvas-actions.ts) → `resolveOpenDoc` at [resolveOpenDoc.ts:139](apps/mail/modules/conversations/components/files/resolveOpenDoc.ts)
   - `openConversation({ conversationId, section: 'files' })` then `setConversationOpenFile(MEETING_PREP_OPEN_FILE)`; ordering matters because `openConversation` clears the open file.
   - `resolveOpenDoc` maps `__meeting_prep__` to the `meeting-prep` agent's overview doc (`SYSTEM_AGENT_NAMES.MEETING_PREP` at [constants.ts:30](apps/mail/modules/conversations/constants.ts)), falling back to a legacy `documentType === 'meeting_prep'` row.
   - **The clicked event is discarded.** Only `conversationId` survives the call, so nothing downstream can be per-meeting.
   - Data after this step:
     ```json
     { "doc": { "id": "0e77…", "path": "conversation/a41b…/agent-b41c…/overview",
                "documentType": "agent" },
       "agentName": "Meeting Prep" }
     ```

7. **Before-meeting runs are scheduled against a specific Google event** — `scheduleBeforeMeetingAutomations` at [calendar-events.ts:278](apps/server/src/services/crm/calendar-events.ts)
   - Resolves the conversation's `aopId`, `userId` and stage; remaps `aopId` for a participant (non-owner) attendee; reads `playbook_manifest.beforeMeetingConfigs`.
   - Per config: skips when `config.stage` is set and does not equal the conversation's normalized status; computes `triggerAt = startTime − minutes`; builds
     ```text
     dedupeExternalId = `${googleEventId}:before-meeting:playbook:${orgAopId ?? aopId}:${stageKey}:${minutes}${participantSuffix}`
     ```
     at [calendar-events.ts:471](apps/server/src/services/crm/calendar-events.ts), and inserts a `pending` `agent_executions` row with `source: 'playbook-before-meeting'`.
   - Cancels stale keys whose `minutes` no longer match the manifest, at [calendar-events.ts:426](apps/server/src/services/crm/calendar-events.ts), matching on `LIKE '{googleEventId}:before-meeting:playbook:%'`.
   - `canceled` rows are deliberately kept and counted in the dedup guard at [calendar-events.ts:490](apps/server/src/services/crm/calendar-events.ts) — *"Without it a cancellation never sticks"* — so any status read must expect canceled rows to persist beside live ones.
   - Data after this step:
     ```json
     { "runId": "run_3d…", "status": "pending",
       "source": "playbook-before-meeting",
       "scheduledFor": "2026-08-27T16:00:00Z",
       "conversationId": "a41b…", "agentId": "b41c…",
       "dedupeExternalId": "7f2c…:before-meeting:playbook:9f2a…:global:60" }
     ```

8. **Statuses are a closed set the frontend already imports** — [execution-status.ts:16](apps/server/src/db/execution-status.ts)
   - `AGENT_EXECUTION_STATUSES = ['pending','executing','completed','canceled','final','failed']`, with `TERMINAL_AGENT_EXECUTION_STATUSES` naming the four that mean the run is over. Re-exported through `@zero/server/schemas` precisely so the frontend branches on real values rather than hand-copied strings.

9. **At fire time the run re-derives its meeting the hard way** — `verifyAndHealBeforeMeetingEvent` at [calendar-events.ts:1358](apps/server/src/services/crm/calendar-events.ts)
   - Re-reads the manifest, computes `scheduledFor + minutes = expectedMeetingStart` for every configured `minutes`, and looks the calendar row up in a ±30-minute window — *"No string parsing of dedup keys."*
   - Returns `cancelled` / `rescheduled` / `ok`, healing the DB and re-scheduling when the meeting moved.
   - Worth noting for §3: the *key* carries the event id exactly, while this function reconstructs it approximately. A read model that wants "prep for THIS meeting" should match on the key, not re-run this arithmetic.

10. **`agent.runNow` can already fire one agent at a deal** — [agent.ts:394](apps/server/src/trpc/routes/agent.ts)
    - Input is `{ agentId, conversationId? }`; it resolves the subagent by filename and calls `runSingleSubagent` with `awaitCompletion: true`. The "run prep now" affordance needs no new mutation.

11. **After the meeting, the pipeline runs without the human's notes** — `handleExecuteMeeting` at [handleExecuteMeeting.ts:94](apps/server/src/mastra/routeHandlers/event-execution/handleExecuteMeeting.ts) → `orchestratorAgentStep` at [on-event-agent-execution-workflow.ts:302](apps/server/src/mastra/workflows/event-execution/on-event-agent-execution-workflow.ts)
    - Context is assembled from the meeting event, `formatHydratedConversationForAgent`, and the rendered playbook; `run-post-event-executor` at [orchestrator-dispatch-tools.ts:433](apps/server/src/mastra/tools/event-execution/orchestrator-dispatch-tools.ts) produces drafts and `userTasks`.
    - Data reaching the executor:
      ```json
      { "event": { "type": "meeting", "transcript": "…", "title": "…" },
        "conversation": { "fields": {…}, "recentEvents": [ … ] },
        "playbook": "<rendered sections>" }
      ```
    - There is no `notes` key, because there is no notes document.

12. **The conversation's meetings are already queryable, narrowly** — `fetchConversationNextStepContext` at [documents.ts:144](apps/server/src/trpc/routes/documents.ts)
    - Selects `calendarEvents` for `(userId, conversationId)` with `endTime > now()`, collapsing a recurring series to its earliest upcoming instance, dropping attendees and every past meeting.
    - Consumed only by the conversation-agenda reconciler at [doc-type-registry.ts:272](apps/server/src/services/documents/doc-type-registry.ts).

13. **A conversation-scoped document needs no registry entry** — `resolveDocType` at [doc-type-registry.ts:448](apps/server/src/services/documents/doc-type-registry.ts)
    - An unknown `documentType` on a `conversation/{id}/{rest}` path is accepted, `scopeId` = the conversation id, stored as `DOCUMENT_TYPE.DOCUMENT`.
    - A *registered* type buys three things a generic one does not: a `seedFn` (how a note template gets applied at creation), a validating `parsePath`, and `broadcastDeltaOnReconcile`.

14. **The deal already lists the meeting-prep agent, in two places** — `sortAgents` at [AgentRow.tsx:72](apps/mail/modules/conversations/components/AgentRow.tsx) and `buildAgentDocs` at [resolveOpenDoc.ts:76](apps/mail/modules/conversations/components/files/resolveOpenDoc.ts)
    - The conversation's agent rows filter through `HIDDEN_AGENT_NAMES` at [AgentRow.tsx:65](apps/mail/modules/conversations/components/AgentRow.tsx) — one entry, `POST_EVENT_TASK_EXECUTOR` — and drop `triggerType === 'cron'` agents.
    - The Files tab's Agents folder filters through a *different* `HIDDEN_AGENT_NAMES` at [resolveOpenDoc.ts:43](apps/mail/modules/conversations/components/files/resolveOpenDoc.ts) — `CRM_UPDATER`, `POST_EVENT_TASK_EXECUTOR`, `TASK_AGGREGATOR` — described as *"agents that exist purely as plumbing"*.
    - Both sets are precedent for hiding an agent. Neither is safe for meeting-prep as written: the Files-tab set hides by **not building the `AgentDoc` at all**, and `resolveOpenDoc`'s `__meeting_prep__` branch at [:146] resolves *through* `agentDocs`. Adding `MEETING_PREP` to that set would silently break every existing `?conversationId={id}/files/__meeting_prep__` notification link.

15. **Archives are already grouped, sorted, and reachable** — `AgentDoc.archives` at [resolveOpenDoc.ts:37](apps/mail/modules/conversations/components/files/resolveOpenDoc.ts), rendered by `AgentFilesGroup` at [AgentFilesGroup.tsx:22](apps/mail/modules/conversations/components/files/AgentFilesGroup.tsx)
    - `isAgentArchiveDoc` at [resolveOpenDoc.ts:53](apps/mail/modules/conversations/components/files/resolveOpenDoc.ts) matches `/agent-{id}/archives/`; the array is sorted descending because *"an archive path ends in an ISO date, so a descending string sort is a date sort"*.
    - `AgentFilesGroup` puts them behind a second collapsed "Show archived" row, on the reasoning that *"an agent writes one per run, so a busy deal has dozens."* For a prep agent that is exactly the history a rep wants, filed where nobody looks.
    - Data after this step:
      ```json
      { "archives": [
          { "id": "cc12…", "path": "conversation/a41b…/agent-b41c…/archives/2026-08-13" },
          { "id": "bb04…", "path": "conversation/a41b…/agent-b41c…/archives/2026-07-29" } ] }
      ```

16. **An archive carries no link to the meeting it prepped** — the archive path is *instructed*, at [automations.ts:982](apps/server/src/services/aop/automations.ts)
    - The `<agent_namespace>` block tells the agent: *"Archive previous overviews to: `conversation/{convId}/agent-{agentId}/archives/{YYYY-MM-DD}`"*. The agent picks the date and writes through `writeDocumentTool`.
    - Nothing stamps the run or the event onto the row — the same gap the agent-workspace design records as **G2** (*"no actorLabel / runId recorded"*). So an archive can only be tied back to a meeting by comparing dates, and the date it carries is the **run** date, which is `minutesBefore` earlier than the meeting and can therefore fall on the previous day.

**Gaps this design closes**

- **G1** — No Meetings tab, so the deal has nowhere to put meeting chrome.
- **G2** — The agent workspace is user-scoped in three places at once ([agent-paths.ts:13](apps/mail/modules/agents/utils/agent-paths.ts), [AgentFileBrowser.tsx:42](apps/mail/modules/agents/components/AgentFileBrowser.tsx), [outputs.ts:37](apps/server/src/services/agent-workspace/outputs.ts)), even though `agentNamespacePath` on the server already takes a scope. An agent cannot be rendered *at a deal*.
- **G3** — The clicked event is discarded at [use-calendar-canvas-actions.ts:15](apps/mail/modules/agentCanvas/hooks/use-calendar-canvas-actions.ts); nothing downstream is per-meeting.
- **G4** — `dedupe_external_id` binds a prep run to one Google event and **nothing reads it back**. There is no way to answer "has prep run for Thursday's call, and if not, why not".
- **G5** — Meeting actions are calendar-only; the conversation view mounts none of them.
- **G6** — No note-taking surface bound to a meeting, and no home for one: the prep brief is archived and overwritten by the next run, so notes typed there do not survive.
- **G7** — No templates for anything note-shaped.
- **G8** — The post-event executor has no notes input, so a captured pain point or a "send them the security doc" line never becomes a CRM field, a task, or a draft.
- **G10** — Past preps are buried behind a "Show archived" chevron inside a folder inside the Files tab, and past *meetings* are not listed anywhere in the deal at all.
- **G11** — An archive row cannot be correlated to the meeting it prepped: it carries a run date and nothing else (see step 16).
- **G12** — Once the Meetings tab exists, the meeting-prep agent is listed three times in one deal — the agent rows, the Files tab's Agents folder, and its own tab — and the two existing hide mechanisms cannot be used as-is without breaking `__meeting_prep__` resolution.
- **G9** — `calendar_events` has no link to any document, so a notes doc could only be found by path-string convention. (See `project_merge_strands_conversation_docs`: path-addressed docs with no FK get orphaned by a merge.)

## 3) Designed state

### 3.1 Architecture diagram

```text
  ┌────────────────────────── CONVERSATION VIEW ───────────────────────────────────┐
  │ ConversationTabs [:25] ── CONVERSATION_TAB_KEYS gains 'meetings'                │
  │   Overview │ Meetings │ Timeline │ Inbox │ Files │ CRM │ Directory              │
  │              └── MeetingsTabTrigger badge: "in 12m" · ● live · ⚠ prep not run   │
  │ ConversationTabBody [:17] ── case 'meetings': <MeetingsTab/>                    │
  │                                                                                 │
  │  ✗ AgentRow list      — meeting-prep dropped (RELOCATED, not hidden)            │
  │  ✗ Files › Agents     — meeting-prep dropped (listed:false, still resolvable)   │
  │     one deal, one place the prep agent lives: this tab.                         │
  └───────────────────────────────────┬────────────────────────────────────────────┘
                                      ▼
  ┌────────────────────────── MeetingsTab.tsx (new) ───────────────────────────────┐
  │ useFocalMeeting(conversationId)  ── crm.listConversationMeetings               │
  │ useMeetingPrepStatus(conversationId, googleEventId)                            │
  │                                                                                │
  │ ┌── MeetingStrip ────────────────────────────────────────────────────────────┐ │
  │ │ ▸ UPCOMING   Cedar × Acme — discovery · Thu 10:00–10:30 · in 12m           │ │
  │ │   ▤▤▤ 3 attendees · 2 accepted           MeetingPicker ▾ (other meetings)  │ │
  │ │ ┌────────────────────────────────────────────────────────────────────────┐ │ │
  │ │ │ PrepStatusChip — the answer to "has prep fired for THIS meeting?"      │ │ │
  │ │ │   scheduled     "Prep runs at 9:00 AM (60m before)"                    │ │ │
  │ │ │   running       "Prep running…"                                        │ │ │
  │ │ │   ready         "Prep ready · 2h ago"                    [Re-run]      │ │ │
  │ │ │   failed        "Prep failed"                            [Run now]     │ │ │
  │ │ │   cancelled     "Prep cancelled — meeting moved"         [Run now]     │ │ │
  │ │ │   not-scheduled "Prep is NOT scheduled — {why}"          [Run now]     │ │ │
  │ │ │        why ∈ no-before-meeting-config · agent-disabled ·               │ │ │
  │ │ │              stage-mismatch · meeting-too-soon · no-aop                │ │ │
  │ │ └────────────────────────────────────────────────────────────────────────┘ │ │
  │ │ MeetingActionRow (phase-aware — before | live | after)                     │ │
  │ └────────────────────────────────────────────────────────────────────────────┘ │
  │                                                                                │
  │ ┌── AgentView ── THE SAME COMPONENT AS /agents/:agentId ────────────────┐ │
  │ │   agentId = the meeting-prep agent · scope = {conversation, conversationId}│ │
  │ │   AgentHeader · AgentTabs   Output │ Config │ Memory │ Previous Runs       │ │
  │ │                                                                            │ │
  │ │  Output  ── AgentOutputTab, leadingPane slot (new) ───────────────────────┐│ │
  │ │    ┌─ MeetingNotesPane  ── the focal meeting's notes, full editor ───────┐ ││ │
  │ │    │   <Document/> over …/agent-{a}/meetings/{eventId}/notes            │ ││ │
  │ │    │   NoteMarkerNode · AgendaTaskNode · @attendee · /slash · templates  │ ││ │
  │ │    └────────────────────────────────────────────────────────────────────┘ ││ │
  │ │    ┌─ MeetingPrepPane  ── the brief, collapsible ────────────────────────┐ ││ │
  │ │    │   <Document/> over …/agent-{a}/overview                            │ ││ │
  │ │    └────────────────────────────────────────────────────────────────────┘ ││ │
  │ │    ┌─ "Past Meeting Preps"  ── just buttons, over archives/ ────────────┐ ││ │
  │ │    │  [Aug 13 · Kickoff] [Jul 29 · Pricing] [Jul 15] [Jul 1] …          │ ││ │
  │ │    │   one per …/agent-{a}/archives/{date}, newest first;               │ ││ │
  │ │    │   click opens that archive in the pane above.                      │ ││ │
  │ │    │   label = date + the meeting it prepped, when correlated           │ ││ │
  │ │    └────────────────────────────────────────────────────────────────────┘ ││ │
  │ │    ┌─ "Previous meetings"  ── the log, paged, newest first ────────────┐ ││ │
  │ │    │  Aug 13  Kickoff            3 attendees   [Notes] [Prep] [▶]      │ ││ │
  │ │    │  Jul 29  Pricing review     2 attendees   [Notes] [Prep]          │ ││ │
  │ │    │  Jul 15  Intro call         4 attendees           [Prep]          │ ││ │
  │ │    │   a row per PAST calendar event on this deal — not per archive.   │ ││ │
  │ │    │   [Notes] when a notes doc exists · [Prep] when an archive        │ ││ │
  │ │    │   correlates · [▶] when a recording exists.                       │ ││ │
  │ │    └────────────────────────────────────────────────────────────────────┘ ││ │
  │ │    ┌─ AgentFileBrowser ── unchanged, rooted at the deal namespace ───────┐ ││ │
  │ │    │  conversation/{convId}/agent-{agentId}/                            │ ││ │
  │ │    │    overview                    ← the current brief                 │ ││ │
  │ │    │    archives/2026-08-13         ← previous briefs  (Past Preps)     │ ││ │
  │ │    │    meetings/7f2c…/notes        ← THIS meeting's notes              │ ││ │
  │ │    │    meetings/3b91…/notes        ← the Aug 13 meeting's notes        │ ││ │
  │ │    └────────────────────────────────────────────────────────────────────┘ ││ │
  │ │  Config  ── identical to /agents/:id (sources · connections · instructions)││ │
  │ │  Memory  ── identical to /agents/:id                                       ││ │
  │ │  Runs    ── identical, filtered to this conversation                       ││ │
  │ └────────────────────────────────────────────────────────────────────────────┘ │
  └────────────────────────────────────────────────────────────────────────────────┘

  ── the scope thread: one parameter, four call sites ──────────────────────────────
  AgentDocScope = {type:'user'} | {type:'conversation', conversationId}
        ├─ client  agentNamespacePath(agentId, scope)      [agent-paths.ts:13]
        ├─ client  AgentFileBrowser({rootPath, scope})     [AgentFileBrowser.tsx:42]
        ├─ server  getAgentOutputs(db, {…, scope})         [outputs.ts:37]
        └─ server  agentNamespacePath(scope, agentId)      [convention-paths.ts:94] ← already scoped
     Default stays {type:'user'} everywhere, so /agents/:agentId is byte-identical.

  ── the prep-status read: the dedup key, finally read back ────────────────────────
  getMeetingPrepStatus [services/meetings/prep-status.ts (new)]
        SELECT run_id, status, scheduled_for, completed_at, cancelled_at, agent_id
          FROM agent_executions
         WHERE user_id = $u
           AND dedupe_external_id LIKE '{googleEventId}:before-meeting:playbook:%'
        │                              └── exact, not a ±30-min reconstruction
        ├─ rows exist ──► pick live (pending|executing) ▸ success (completed|final)
        │                      ▸ failed ▸ canceled   → state + timestamps
        └─ no rows ────► diagnose WHY, via the SHARED config selector:
                selectBeforeMeetingConfigs(manifest, stage, isParticipant)
                ── extracted from scheduleBeforeMeetingAutomations [:278] so the
                   status read cannot drift from the scheduling rule it explains ──
                  []                         → 'no-before-meeting-config'
                  agent.enabled === false    → 'agent-disabled'
                  configs all stage-scoped ≠ conversation.status → 'stage-mismatch'
                  min(startTime − minutes) ≤ now → 'meeting-too-soon'
                  conversation.aopId == null → 'no-aop'
        [Run now] ──► agent.runNow({ agentId, conversationId })  [agent.ts:394]  (exists)

  ── correlating an archive to the meeting it prepped ─────────────────────────────
  FORWARD (exact)   writeDocument stamps, server-side, on any …/archives/{date} write:
                      metadata.runId          ← the ambient execution scope
                      metadata.googleEventId  ← parsed from that run's dedupe_external_id
                                                when it matches the before-meeting shape
                    Closes G2 for archives. No prompt dependency: the agent is not asked.
  HISTORICAL (best-effort)  correlateArchives(archives, meetings)
                      archive ISO date == meeting's LOCAL start date
                      else the nearest meeting starting within 48h AFTER that date
                        (a 12h-before prep for a 10:00 meeting archives the day before)
                      An archive that correlates to nothing is STILL listed under
                      Past Meeting Preps, unlabelled. Never hidden for failing to match.

  ── relocating the agent out of the two deal surfaces ────────────────────────────
  AgentRow.tsx:65   HIDDEN_AGENT_NAMES += MEETING_PREP        → dropped from agent rows
  resolveOpenDoc    RELOCATED_AGENT_NAMES (NEW set, ≠ HIDDEN) → AgentDoc.listed = false
        │             built and resolvable, simply not listed. HIDDEN means "never
        │             built"; meeting-prep must stay built because resolveOpenDoc's
        │             '__meeting_prep__' branch resolves THROUGH agentDocs [:146].
        └── FilesTab filters on `listed`; the sentinel keeps resolving; old
            notification deep-links keep working and land on the Meetings tab.

  ── AFTER the meeting ────────────────────────────────────────────────────────────
  handleExecuteMeeting [:94]
        │  + loadMeetingNotesForEvent(userId, conversationId, googleEventId)
        │      resolves calendar_events.notes_document_id → { markdown, markers[] }
        ▼
  onEventAgentExecutionWorkflow [:1056]
        preExecutionSetupStep [:243] ── carries meetingNotes into the step payload
        orchestratorAgentStep  [:302] ── rendered as "## Human notes from this meeting",
        │                                 authoritative over the transcript on intent
        ├─► run-post-event-executor [:433]   task/next-step markers → userTasks
        ├─► run-crm-updater                  pain/objection/competitor → CRM fields
        └─► appendTriageSection (new)        "## Triage — {ts}" appended to the SAME
                                             notes doc via applyUpdate(origin='agent')
                                             + calendar_events.notes_triaged_at
        The notes doc stays live: the user keeps editing, the agent's section arrives
        over SSE (useDocEvents). No overwrite, no archive — unlike the brief.
```

### 3.2 Step-by-step walkthrough

0. **AMENDED — where `AgentDocScope` is defined.** The plan said to re-export the type *from* [convention-paths.ts:88](apps/server/src/services/documents/convention-paths.ts) *through* `agent-workspace/types.ts`. That is backwards and would have broken the rule `types.ts` states in its own header: `convention-paths.ts` imports `./index` — the document service, and with it Yjs, S3 and drizzle — so re-exporting from there drags that whole graph into the browser's type graph. The direction is inverted: `AgentDocScope` is now **defined** in the dependency-free `types.ts`, and `convention-paths.ts` re-exports it. One definition, no drift, no new edge.

1. **The agent namespace becomes scope-aware on the client** — `agentNamespacePath` at [agent-paths.ts:13](apps/mail/modules/agents/utils/agent-paths.ts)
   - Signature becomes `agentNamespacePath(agentId: string, scope: AgentDocScope = { type: 'user' })`, mirroring the server helper at [convention-paths.ts:94](apps/server/src/services/documents/convention-paths.ts) that has taken a scope all along. `agentMemoryDirPath` takes the same optional scope.
   - The module comment's claim — *"An agent's namespace is USER-scoped, not a scope of its own"* — is corrected in place: user scope is the default, not the only one; a conversation-scoped agent writes under the deal.
   - Data after this step:
     ```json
     { "user":         "user/agent-b41c…",
       "conversation": "conversation/a41b…/agent-b41c…" }
     ```

2. **The file browser accepts the scope its tree already supports** — `AgentFileBrowser` at [AgentFileBrowser.tsx:42](apps/mail/modules/agents/components/AgentFileBrowser.tsx)
   - New optional `scope?: AgentDocScope`. `useFileTree` is mounted with `{ type: 'user', id: userId }` today; conversation scope passes `{ type: 'conversation', id: conversationId }` — the same `FileTreeScope` union the deal Files tab already uses, so no tree work is needed.
   - Everything else stays: same rows, same chevrons, same rename/delete/drag, no Drive mount, no attachments section.

3. **The server read model takes the scope** — `getAgentOutputs` at [outputs.ts:37](apps/server/src/services/agent-workspace/outputs.ts)
   - Params gain `scope: AgentDocScope`, defaulting to `{ type: 'user' }`; `namespace` becomes `agentNamespacePath(scope, agentId)`.
   - The `eq(documents.userId, userId)` predicate is kept for user scope and **dropped** for conversation scope — a deal's agent folder is shared by the conversation's editors, exactly as the rest of the deal's documents are.
   - `inConversations.count` is only meaningful in user scope; in conversation scope it is `0` and the section does not render. The comment at [AgentOutputTab.tsx:91](apps/mail/modules/agents/components/AgentOutputTab.tsx) — *"a meeting-prep doc belongs to the deal it preps"* — stays true: in the deal, that doc is finally listed as what it is.
   - Data after this step (conversation scope):
     ```json
     { "owned": [
         { "documentId": "0e77…", "name": "overview",
           "path": "conversation/a41b…/agent-b41c…/overview" },
         { "documentId": "5aa1…", "name": "notes",
           "path": "conversation/a41b…/agent-b41c…/meetings/7f2c…/notes" },
         { "documentId": "cc12…", "name": "2026-08-13",
           "path": "conversation/a41b…/agent-b41c…/archives/2026-08-13" } ],
       "touched": [],
       "inConversations": { "count": 0 } }
     ```

4. **`agent.getOutputs` and `agent.getRuns` accept a conversation** — [agent.ts:323](apps/server/src/trpc/routes/agent.ts) and [agent.ts:343](apps/server/src/trpc/routes/agent.ts)
   - Both inputs gain `conversationId: z.string().uuid().optional()`. When present, `requireAgent` at [agent.ts:94](apps/server/src/trpc/routes/agent.ts) is followed by a conversation-membership assertion — an agent grant is not a deal grant, and the two boundaries must both hold.
   - **AMENDED — which membership helper.** "the one the CRM procedures use" was ambiguous: `crm.ts` uses `assertConversationMembership` (member row, owner fallback) while `documents.ts` uses `assertConversationOrgAccess` (org-wide). The build uses `assertConversationMembership`, because that is what guards `files.listChildren` — the very query `AgentFileBrowser` issues under conversation scope. Org-access here would produce a tab whose read model returns rows its own tree then refuses to show. Note this is *stricter* than the read model's `orgId` bound; if the workspace should be org-visible like the rest of a deal's documents, this is the line to revisit.
   - **AMENDED — `getAgentRuns` is not in `agent-read.ts`.** It is a private function at [agent.ts:885](apps/server/src/trpc/routes/agent.ts); the `conversation_id` filter went there.
   - `getAgentRuns` gains an optional `conversationId` filter on `agent_executions.conversation_id`, so Previous Runs in a deal shows this deal's runs.

5. **`AgentView` takes a scope and an optional leading pane** — [AgentView.tsx:44](apps/mail/modules/agents/components/AgentView.tsx)
   - New props: `scope?: AgentDocScope` (default `{ type: 'user' }`), `outputLeadingPane?: ReactNode`, and `chrome?: 'page' | 'embedded'`.
   - `chrome: 'embedded'` suppresses `AgentBackGutter` and the `h-full` page wrapper — inside a conversation the gutter and scroll region already belong to `ConversationBodyLayout` — and nothing else. Header, tab strip, paddings, and the 75ch `CONVERSATION_COLUMN` are untouched, which is what makes the deal's meeting surface and `/agents/:agentId` the same screen.
   - Tab state is prop-**or**-URL, not a move: the component is controlled iff `tab` is supplied, and `/agents/:agentId` keeps its `?tab=` behaviour untouched.
   - All five queries pass `scope.type === 'conversation' ? { conversationId } : {}` through.

6. **A meeting-shaped entry point opens the tab** — `openMeeting` in `apps/mail/modules/conversations/utils/open-meeting.ts` (new)
   - `openMeeting({ conversationId, eventId })` calls `openConversation({ conversationId, section: 'meetings' })` then `setFocalMeetingId(eventId)` — the same ordering discipline as `handleOpenMeetingPrepFile`, because `openConversation` clears view-scoped keys.
   - `eventId` is the **Google** event id, which is what every calendar surface holds and what the dedup key is built from.
   - Data after this step:
     ```json
     { "activeConversationId": "a41b…", "conversationSection": "meetings",
       "focalMeetingId": "7f2c…" }
     ```

7. **The tab key is added** — `CONVERSATION_TAB_KEYS` at [conversationsSlice.ts:38](apps/mail/modules/conversations/slice/conversationsSlice.ts)
   - `'meetings'` inserted after `strategicOverview`. `normalizeConversationSection` at [conversationsSlice.ts:64](apps/mail/modules/conversations/slice/conversationsSlice.ts) folds `meetings/{eventId}` into `meetings` plus a `focalMeetingId` write, mirroring how `agent/{agentId}` folds into `files`.
   - `TAB_LABELS` at [ConversationTabs.tsx:12](apps/mail/modules/conversations/components/ConversationTabs.tsx) gains `meetings: 'Meetings'`. The key is plural to match the label and the tab's actual content: one focal meeting on top, every past meeting logged below. The deep-link is `meetings/{eventId}` — a collection addressed at one member, the same shape as `files/{docId}`.
   - A `MeetingsTabTrigger` (sibling of `CrmTabTrigger`) renders the badge and hides the tab when the deal has no meetings and no prep archives.

8. **The focal meeting is resolved** — `useFocalMeeting` in `apps/mail/modules/conversations/hooks/use-focal-meeting.ts` (new)
   - Calls `crm.listConversationMeetings` (step 9) and picks deterministically: an explicit `focalMeetingId` wins; else the meeting whose `[start − 5m, end + 15m]` window contains now; else the earliest meeting with `startTime > now`; else the most recent meeting ending in the last 24h; else the most recent meeting.
   - Derives `phase` from the same window (`before | live | after`), re-derived on a 30s tick so a tab left open crosses phases without a refetch.
   - The strip always labels the focal meeting as **Upcoming** when `phase === 'before'`, which is the "definitely show the upcoming meeting" requirement — the default focal choice for a deal with future meetings is always the next one.
   - Data after this step:
     ```json
     { "phase": "before",
       "focal": { "id": "c33e…", "googleEventId": "7f2c…",
                  "title": "Cedar × Acme — discovery",
                  "startTime": "2026-08-27T17:00:00Z",
                  "endTime": "2026-08-27T17:30:00Z",
                  "meetingLink": "https://meet.google.com/abc-defg-hij",
                  "attendees": [ { "email": "<email>", "displayName": "Dana R.",
                                   "responseStatus": "accepted", "self": false } ],
                  "selfResponseStatus": "accepted",
                  "notesDocumentId": null, "notesTriagedAt": null },
       "others": [ { "id": "d90a…", "title": "Cedar × Acme — kickoff",
                     "startTime": "2026-08-13T16:00:00Z" } ] }
     ```

9. **The server lists the deal's meetings** — `listConversationMeetings` in [apps/server/src/trpc/routes/crm.ts](apps/server/src/trpc/routes/crm.ts) (new)
   - Input `{ conversationId, windowDays?: number }` (default 30). Asserts conversation membership, selects `calendarEvents` on `(userId, conversationId)` within the window, ordered by `startTime`.
   - Deliberately wider than `fetchConversationNextStepContext` at [documents.ts:144](apps/server/src/trpc/routes/documents.ts), which drops attendees and past meetings. Recurring series are **not** collapsed: a weekly sync's instances each get their own prep run and their own notes.

10. **Prep status is read off the dedup key** — `getMeetingPrepStatus` in `apps/server/src/services/meetings/prep-status.ts` (new)
    - `SELECT run_id, status, scheduled_for, completed_at, cancelled_at, agent_id, dedupe_external_id FROM agent_executions WHERE user_id = $u AND dedupe_external_id LIKE '{googleEventId}:before-meeting:playbook:%'`, ordered by `created_at DESC`.
    - This is the exact prefix written at [calendar-events.ts:471](apps/server/src/services/crm/calendar-events.ts) and matched at [calendar-events.ts:426](apps/server/src/services/crm/calendar-events.ts) — an exact event binding, not the ±30-minute reconstruction `verifyAndHealBeforeMeetingEvent` at [calendar-events.ts:1358](apps/server/src/services/crm/calendar-events.ts) has to do at fire time.
    - Row precedence, because `canceled` rows persist by design (see [calendar-events.ts:490](apps/server/src/services/crm/calendar-events.ts)): a live row (`pending | executing`) wins, then a success (`completed | final`), then `failed`, then `canceled`.
    - **AMENDED — the LIKE pattern MUST be escaped, and the plan does not say so.** A recurring instance id looks like `nqprmq8iq8ohjf9dqrirhrfs34_20260828T170000Z`. `_` is a LIKE single-character wildcard, so an unescaped prefix match would also match sibling event ids differing at that position — silently attributing one instance's prep run to another. `escapeLikePattern` is required, not optional.
    - **AMENDED — two columns the state union assumed do not exist.** `agent_executions` has no `error` column (the build uses `summary`) and no `started_at` (it uses `updated_at` for the `running` timestamp).
    - **AMENDED — `ready.documentId` is almost always null.** Before-meeting executions are enqueued from the playbook manifest, which names no `aop_agents` row, so `agent_executions.agent_id` is NULL on every real row inspected. The frontend must read a null `documentId` as "open the Output tab", never as "there is no brief".
    - Data after this step:
      ```json
      { "state": "scheduled", "runId": "run_3d…",
        "scheduledFor": "2026-08-27T16:00:00Z", "minutesBefore": 60,
        "agentId": "b41c…" }
      ```

11. **"Not scheduled" is diagnosed, not shrugged at** — `selectBeforeMeetingConfigs` in `apps/server/src/services/crm/before-meeting-configs.ts` (new), extracted from `scheduleBeforeMeetingAutomations` at [calendar-events.ts:278](apps/server/src/services/crm/calendar-events.ts)
    - The stage-matching predicate (normalize `*`→`_`, lowercase, compare to the conversation's status) and the participant `orgAopId === null` filter move into one pure function, called by both the scheduler and the status read. **This is the point of the extraction:** a status chip that explains why prep is not scheduled must use the same rule that decided not to schedule it, or it will confidently give the wrong reason.
    - With no execution rows, the reason is resolved in order: `no-aop` → `no-before-meeting-config` → `agent-disabled` → `stage-mismatch` → `meeting-too-soon` → `unknown`.
    - **AMENDED — only THREE of those are scheduling blockers, and the parity test as originally specified was unsatisfiable.** The plan assumed every reason came from the rule the scheduler applied. Two do not:
      - `agent-disabled` — `scheduleBeforeMeetingAutomations` never reads the subagent document. A disabled agent still gets a run **enqueued**; it refuses later, at [run-single-subagent.ts:90](apps/server/src/services/playbook/run-single-subagent.ts).
      - `meeting-too-soon` — the scheduler does not skip a past `triggerAt`, it **clamps** it to `now + 1s`.
      So `PREP_SCHEDULING_BLOCKERS = ['no-aop', 'no-before-meeting-config', 'stage-mismatch']`, and the parity test asserts `wouldScheduleBeforeMeeting(cell) === !isPrepSchedulingBlocker(reason)` cell by cell, with the two non-enforced reasons asserted separately. The reason ORDER is unchanged — `agent-disabled` still outranks `stage-mismatch`, because fixing the stage changes nothing while the agent is off.
    - **AMENDED — `no-aop` is rarer than the plan implies.** A null `conversation.aopId` does not mean no prep: the scheduler falls back to the user's `Deals` AOP, and a participant is remapped to their org-equivalent AOP. `no-aop` means "no AOP resolved *after* the fallback and the remap".
    - **AMENDED — a sixth blocking condition the plan omits:** the scheduler also bails when the playbook *document* carries no `orgId`, regardless of the manifest. Folded into `no-before-meeting-config`, since the two absences have one useful answer.
    - **AMENDED — read the manifest the SCHEDULER's way.** The plan said to reuse `verifyAndHealBeforeMeetingEvent`'s approach; that function reads only the *user's* manifest, while the scheduler uses `getBeforeMeetingConfigsForUser` (user + org merged, carrying `orgAopId` origin). Parity forces the scheduler's reader.
    - Data after this step:
      ```json
      { "state": "not-scheduled", "reason": "stage-mismatch",
        "detail": { "conversationStage": "discovery",
                    "configuredStages": ["proposal","negotiation"] } }
      ```

12. **The strip renders the status and offers the fix** — `MeetingStrip` + `PrepStatusChip` in `apps/mail/modules/conversations/components/meeting/` (new)
    - The chip renders one line per state (see §3.1). `not-scheduled`, `failed` and `cancelled` carry a **Run prep now** button calling `agent.runNow({ agentId, conversationId })` at [agent.ts:394](apps/server/src/trpc/routes/agent.ts) — which already exists and already takes a conversation. `ready` carries a quieter **Re-run**.
    - `not-scheduled` renders in amber with the reason in plain words ("Prep is not scheduled — this deal is in Discovery, and prep is only configured for Proposal and Negotiation"), and links to the workspace's own Config tab, which is one tab away on the same screen.

13. **The notes document lives in the agent's namespace** — `buildDocPath.meetingNotes` in [buildDocPath.ts](apps/mail/modules/files/store/buildDocPath.ts), `meetingNotesPath` in [convention-paths.ts:86](apps/server/src/services/documents/convention-paths.ts)
    - Path: `conversation/{conversationId}/agent-{agentId}/meetings/{googleEventId}/notes` — i.e. `agentNamespacePath({type:'conversation', conversationId}, agentId) + '/meetings/{googleEventId}/notes'`, minted by the helper rather than hand-built, exactly as `agentArchivePath` mints `…/archives/{date}`.
    - This is what "the file is linked to the meeting-prep agent" means concretely: the notes doc is **inside the agent's folder**, so it is listed by `getAgentOutputs` with no special case, it appears in the workspace's file tree beside `overview` and `archives/`, and `document_updates.actor_agent_id` attributes agent writes to it.
    - Data after this step:
      ```json
      { "id": "5aa1…",
        "path": "conversation/a41b…/agent-b41c…/meetings/7f2c…/notes",
        "documentType": "document", "version": 1,
        "metadata": { "source": "template", "templateSlug": "discovery",
                      "googleEventId": "7f2c…", "agentId": "b41c…" } }
      ```

14. **The type is registered so it can be seeded from a template** — `meetingNotesDef` in [doc-type-registry.ts](apps/server/src/services/documents/doc-type-registry.ts), beside `conversationAgendaDef` at [:272](apps/server/src/services/documents/doc-type-registry.ts)
    - `parsePath` matches `^conversation/([^/]+)/agent-([^/]+)/meetings/([^/]+)/notes$` → `{ scopeId: convId, subPath: '{agentId}/{googleEventId}' }`.
    - `seedFn` calls a new `fetchers.resolveNoteTemplate(userId, orgId, conversationId)` and prepends a `# {title} — {date}` heading.
    - `storedDocumentType` is `DOCUMENT_TYPE.DOCUMENT` — no `documents.document_type` migration; `meeting_notes` is a *logical* type, as `deal_overview` already is at [doc-type-registry.ts:319](apps/server/src/services/documents/doc-type-registry.ts).
    - `reconcile` is deliberately absent: the notes doc is human-authored, nothing server-derived belongs in it, and a reconciler would fight the user's cursor.

15. **The doc id is written back onto the calendar event** — `linkNotesDocument` in `apps/server/src/services/meetings/meeting-notes.ts` (new), called from the `getDoc` create path
    - `UPDATE calendar_events SET notes_document_id = $docId WHERE user_id = $u AND conversation_id = $c AND google_event_id = $e AND notes_document_id IS NULL`.
    - The path is the *address*; this column is the *authority*. Path-only addressing is what orphaned 510 conversation documents in the strands merge, and the post-event pipeline must be able to find a meeting's notes without string-matching.

16. **The Output tab leads with notes, then the brief, then the tree** — `AgentOutputTab` at [AgentOutputTab.tsx:91](apps/mail/modules/agents/components/AgentOutputTab.tsx)
    - One new optional prop, `leadingPane?: ReactNode`, rendered above `AgentFileBrowser`. `/agents/:agentId` passes nothing and is unchanged.
    - `MeetingsTab` passes `<><MeetingNotesPane/><MeetingPrepPane/></>` — notes first, because that is what you are doing during the call; the brief below it, collapsed by default once the meeting starts; the file tree beneath both, which is how you reach a past meeting's notes or an archived brief.
    - `MeetingNotesPane` mounts `Document` at [document.tsx:153](apps/mail/modules/documents/document.tsx) with the notes doc; `MeetingPrepPane` mounts the same primitive over the agent's `overview`. Both are the components the workspace already uses — no bespoke renderer.
    - **AMENDED — the prep pane must NOT resolve the brief with `documents.getDoc`.** `getDoc` is FIND-OR-CREATE. Asking it would mint an empty brief on every deal that has never run prep, making "prep has never run" permanently unobservable and killing the "Run meeting prep" affordance this same step asks for. The pane reads `crm.listConversationDocs` — already warm in the conversation view — and matches on the overview path instead. Pinned by a test.
    - **AMENDED — a marker inside a bullet list did not round-trip.** The markdown renderer emits its own `- ` for the list item, so a chip on a bulleted line came out `- - [pain] …`, which `NOTE_MARKER_RE` rejects. The rep's line would have vanished from triage with no error anywhere. `renderMarkdown` drops its leading `- ` when nested.
    - **AMENDED — three duplication paths need three mechanisms**, not the single paste rule the plan implies: a copied CHIP arrives as a node (`transformPasted`), raw marker MARKDOWN arrives as text (a `PasteRule` over the contract regex), and Enter inside a chip splits it (`keepOnSplit: false` plus an `appendTransaction` heal). Re-minting must happen at paste time, not as a de-dupe pass over the finished document: pasting a marker ABOVE its original would otherwise re-mint the ORIGINAL and strand a `user_tasks.source_marker_id` triage had already written.
    - **AMENDED — mounting `AgendaTaskNode` does not bind tasks to the deal.** Its `addAttributes` defaults `conversationId` to null and its `[]` input rule sets it null explicitly, so an `appendTransaction` stamps the deal onto any unbound task — which also covers Enter-splits, pastes and the action row's add-task. `useMeetingEditorExtensions` therefore needs `conversationId`, which the plan's signature omits.
    - The brief's storage does not move. It stays the agent's overview doc, still archived and rewritten by the next prep run. That is precisely why notes need their own file.

17. **Past Meeting Preps is a row of buttons over `archives/`** — `PastMeetingPrepsSection` in `apps/mail/modules/conversations/components/meeting/PastMeetingPrepsSection.tsx` (new)
    - Source is the agent's own archive list, which already exists: `AgentDoc.archives` at [resolveOpenDoc.ts:37](apps/mail/modules/conversations/components/files/resolveOpenDoc.ts), built by `isAgentArchiveDoc` at [:53] and already sorted newest-first by ISO-date string. Nothing new is queried and nothing new is stored — the section is a second, better-placed rendering of a list the Files tab has always had behind a "Show archived" chevron.
    - Renders as **buttons, not rows**: a wrapped strip of chips, newest first, each labelled `{Mon D}` plus the meeting it prepped when one correlates (`Aug 13 · Kickoff`). Clicking one opens that archive **in the prep pane above**, with a "viewing an archived prep" bar and a way back to the current brief — it does not navigate away.
    - Collapsed past the first eight chips behind a `+ 14 more`, because a two-year deal has a lot of preps and this section sits between the brief and the meeting log.
    - Data after this step:
      ```json
      [ { "documentId": "cc12…", "date": "2026-08-13",
          "label": "Aug 13 · Kickoff", "googleEventId": "3b91…" },
        { "documentId": "bb04…", "date": "2026-07-29",
          "label": "Jul 29", "googleEventId": null } ]
      ```

18. **Archives are correlated to meetings — exactly going forward, best-effort behind** — `correlateArchives`, and a stamping change in `writeDocument`
    - **AMENDED — it lives on the SERVER**, at `apps/server/src/services/meetings/correlate-archives.ts`, not in apps/mail as planned, and reaches the client through `types/meeting.ts`. Same reasoning as `focal-meeting.ts`: `cedar-cli meetings preps` runs the identical ladder, and a CLI that correlated archives differently from the tab would prove nothing about the tab.
    - **Forward, exact:** `writeDocument` at [documents/index.ts:361](apps/server/src/services/documents/index.ts) gains a stamp for any path matching `…/agent-{id}/archives/{date}`: `metadata.runId` from the ambient execution scope, and `metadata.googleEventId` parsed off that run's `dedupe_external_id` when it matches the before-meeting shape written at [calendar-events.ts:471](apps/server/src/services/crm/calendar-events.ts). This is done **server-side, not by instructing the agent** — the archive path is currently a prompt instruction at [automations.ts:982](apps/server/src/services/aop/automations.ts), and a correlation that depends on an LLM remembering to pass metadata is not a correlation. It also closes agent-workspace gap **G2** for archive rows.
    - **Historical, best-effort:** archives written before the stamp have only a run date. `correlateArchives` matches an archive's ISO date against each meeting's **local** start date, then falls back to the nearest meeting starting within 48h *after* that date — a 12-hours-before prep for a 10:00 meeting archives on the previous day, so an exact date match alone would drop it.
    - An archive that correlates to nothing is still listed, unlabelled. The section's job is "let me open a past prep"; failing to name it is not a reason to hide it.

18b. **AMENDED — three things the plan missed about these two sections.**
    - **`agent.getOutputs` did not return `metadata`, so the `stamped` rung was dead on arrival.** `AgentOutputFile` carried only `{documentId, name, path, documentType, updatedAt, relation}`, and the query selected to match. The write-side stamp was landing and nothing could read it, so *every* archive fell through to date-matching. Fixed by selecting and surfacing `metadata` (null on the `touched` arm, which GROUPs by document). A stamp is only as useful as the read that surfaces it.
    - **tRPC's `infiniteQueryOptions` cannot page this route.** It hard-codes `{ cursor: pageParam }` into the input, while `listConversationMeetingLog` names its cursor `before` on a zod object that strips unknown keys — the cursor would be silently dropped and "Show more" would append page 1 forever. The hook keeps an explicit cursor list over `useQueries` instead.
    - **The server's cursor is inclusive** (`start_time <= before`), so two meetings sharing a timestamp can straddle a page boundary. The hook de-dupes by row id.

19. **Previous meetings is a paged log of the deal's history** — `PreviousMeetingsLog` in `apps/mail/modules/conversations/components/meeting/PreviousMeetingsLog.tsx` (new), over `crm.listConversationMeetingLog` (new)
    - One row per **past calendar event** on this deal — not per archive, which is the distinction between this section and the one above. Answers "what meetings have we had", where Past Meeting Preps answers "give me a past brief".
    - Each row: date, title, attendee count with an avatar stack, the user's RSVP outcome, and up to three affordances — **Notes** when `notes_document_id` is set, **Prep** when an archive correlates, **▶** when the meeting has a recording. A row with none of the three still renders: a meeting that happened with no artifacts is itself information.
    - Paged rather than windowed: `listConversationMeetings` (step 9) serves the strip and the picker over a ±30-day window; the log needs the whole history, so it is a separate cursor-paged read (`before` + `limit`, default 10, "Show more"). A two-year deal must not ship 200 rows to render three.
    - Recurring instances are listed individually, for the same reason they are not collapsed in step 9: each has its own prep run and its own notes.
    - Data after this step:
      ```json
      { "rows": [
          { "id": "d90a…", "googleEventId": "3b91…", "title": "Kickoff",
            "startTime": "2026-08-13T16:00:00Z", "attendeeCount": 3,
            "selfResponseStatus": "accepted",
            "notesDocumentId": "77ab…", "prepArchiveDocumentId": "cc12…",
            "hasRecording": true } ],
        "nextCursor": "2026-07-29T15:00:00Z" }
      ```

20. **The notes editor is a full editor with invocations** — `MeetingNotesPane` in `apps/mail/modules/conversations/components/meeting/MeetingNotesPane.tsx` (new)
    - `extraExtensions`: `useRichTextExtensions()` at [use-rich-text-extensions.ts:47](apps/mail/modules/documents/use-rich-text-extensions.ts) (file links, `@` conversation and event mentions); `attendeeSuggestion`, an `@` source built from `event.attendees`, modelled on `colleagueSuggestion` in [OverviewDocTab.tsx:157](apps/mail/modules/conversations/components/OverviewDocTab.tsx); `AgendaTaskNode` from [AgendaTaskNode.tsx:980](apps/mail/modules/agentCanvas/extensions/AgendaTaskNode.tsx) so a task line in notes is a **real** task (checkbox, due date, Cmd+D invoke); and the new `NoteMarkerNode`.
    - `extraSlashCommands`: one `SlashCommandItem` ([types.ts](apps/mail/components/slash-command/types.ts)) per marker kind, plus `/template`, `/ask-cedar` (sends the selection to chat with meeting context attached) and `/draft-followup`.
    - Data after a `/pain` insertion:
      ```json
      { "type": "noteMarker",
        "attrs": { "kind": "pain", "markerId": "1f0c", "resolved": false },
        "content": [ { "type": "text",
                       "text": "manual CRM hygiene eats 6 hrs/week per rep" } ] }
      ```

21. **Markers serialize to markdown the agent can read** — `note-markers.ts` in `apps/server/src/services/meetings/` (new), **re-exported** to the client
    - Markdown form: `- [pain] manual CRM hygiene eats 6 hrs/week per rep <!--m:1f0c-->`
    - `parseNoteMarkers(markdown)` turns that back into `NoteMarker[]`.
    - **AMENDED DURING IMPLEMENTATION.** The plan said "mirrored client module + a parity test asserting the two regex sources are identical". That is one definition too many. `apps/mail` already imports server leaf modules at RUNTIME through the package exports map (`@zero/server/schemas`, `@zero/server/table`), and the repo has already solved exactly this problem once: the `[[type: id]]` cell-ref grammar was de-duplicated to a single server definition, and its test "asserts identity rather than parity" ([cell-refs.test.ts](apps/mail/tests/modules/documents/table/cell-refs.test.ts)). One `exports` entry — `"./meetings/note-markers"` — gets the frontend the real module. A parity test only catches divergence after it happens; one definition makes it impossible. The test that remains asserts `NOTE_MARKER_RE === server.NOTE_MARKER_RE` by object identity, which is what proves the export map resolves inside the frontend's toolchain.
    - The regex is GENERATED from `NOTE_MARKER_KINDS` rather than restating the eight names, so the closed set and the alternation enforcing it cannot drift.
    - The `$` anchor is load-bearing: `- [task] fix the marker <!--m:aaaa--> that Bob pasted <!--m:88be-->` binds to `88be`. Without it, a rep quoting a marker back into a note silently retargets triage.
    - This is the contract between the editor and the pipeline; it is deliberately *not* a table, because a row would immediately disagree with an undo.
    - **Two edge cases the plan did not specify**, both settled during implementation: a marker with EMPTY text parses (the parser reports the line, it does not judge it) and triage skips it rather than creating a blank task; and a COPY-PASTED marker line yields two markers sharing one id, which `parseNoteMarkers` returns as-is — dropping a line the user can still see would be worse — so `NoteMarkerNode` re-mints the id on paste (Phase 6) and triage de-duplicates within a run.

22. **Templates seed the notes** — `note-templates.ts` in `apps/server/src/services/meetings/` (new) and `MeetingTemplatePicker` on the client
    - `resolveNoteTemplate` resolves user (`user/meeting-note-templates/{slug}` with `metadata.isDefault`) → org (`organisation/meeting-note-templates/{slug}`) → `BUILT_IN_MEETING_NOTE_TEMPLATES` (discovery, demo, follow-up, internal). User shadows org on slug collision, matching the subagent precedence rule.
    - **AMENDED — `documents.list` cannot see org templates.** It hard-filters `eq(documents.userId, userId)`, and an org template row is created with `userId = null`, so the org half of the merge would silently always come back empty. A `meetings.listNoteTemplates` procedure was added instead, wrapping the server's own `listMeetingNoteTemplates` — the precedence rule belongs beside the seed that applies it, so the picker offers exactly the template `resolveNoteTemplate` would choose. Applying to a non-empty doc **appends below the caret**, never replaces.
    - **Still outstanding:** the four built-ins are not seeded as documents yet, and `note-templates.ts` imports drizzle so it is not browser-importable. A fresh account therefore sees an empty picker — but notes are never blank, because the seed still applies the built-in default server-side.
    - "Save as template" writes the current notes markdown (title heading and marker text stripped, marker headings kept) to `user/meeting-note-templates/{slug}`. Templates are editable in `/brain` like any other document, because they are documents.

23. **The action row is assembled from existing handlers** — `MeetingActionRow` + `MEETING_ACTIONS` in `apps/mail/modules/conversations/components/meeting/` (new)
    - A declarative registry: `{ id, label, icon, phases, enabled(ctx), run(ctx) }`, with `ctx` carrying `{ event, conversationId, phase, isOrganizer, hasOtherAttendees, notesDocId, editor }`.
    - Every `run` delegates; nothing is reimplemented. `join` → the `handleJoinMeet` logic at [EventDetailsPopover.tsx:2131](apps/mail/modules/calendar/components/EventDetailsPopover.tsx), lifted to `modules/calendar/utils/joinMeeting.ts`; `rsvp` → the mutation behind `handleRsvpChange` at [:1850], lifted to `use-event-rsvp.ts`; `reschedule-email` → `sendReschedulePrompt` at [meetingEmailPrompts.ts:97](apps/mail/modules/calendar/utils/meetingEmailPrompts.ts); `reschedule-event` → `EventDetailsPopover` in edit mode with its notify/don't-notify confirm at [:1651]; `no-show` → `sendNoShowPrompt` at [meetingEmailPrompts.ts:92](apps/mail/modules/calendar/utils/meetingEmailPrompts.ts); `follow-up` → `scheduleFollowUp` at [calendarFollowUpUtils.ts:62](apps/mail/modules/calendar/utils/calendarFollowUpUtils.ts); `cancel` → `calendar.deleteEvent` with the recurring-scope submenu from [CalendarEventContextMenu.tsx:39](apps/mail/modules/calendar/components/CalendarEventContextMenu.tsx); `add-task` → inserts an `agendaTask` node at the notes cursor.
    - Per phase:
      ```ts
      before: ['join','rsvp','reschedule-event','reschedule-email','no-show','add-attendee','add-meet','cancel']
      live:   ['join','follow-up','add-task','no-show','end-and-triage']
      after:  ['follow-up','draft-recap','run-triage','open-recording','next-meeting']
      ```
    - Past four visible buttons, the rest collapse into a `MoreHorizontal` dropdown so the row never wraps.
    - **AMENDED — `follow-up` and `next-meeting` are the SAME action.** `scheduleFollowUp`'s own header calls itself "equivalent to pressing Schedule Follow-Up in EventDetailsPopover" — i.e. `handleCreateNextMeeting`, which the plan lists separately. Both in `after` renders the identical button twice; they are split by phase instead (`follow-up` in `live`, `next-meeting` in `after`).
    - **AMENDED — the specified `ctx` cannot delegate.** Four `run`s need a tRPC mutation, the RSVP hook, or the popover, none reachable from a module-scope function. `MeetingActionContext` gains one field, `deps`, which the row binds and hands down — without it the registry would have to re-implement the handlers it exists to reuse.
    - **AMENDED — `draft-recap` had no handler to delegate to**; `buildRecapPrompt` / `sendRecapPrompt` were added beside the two sibling prompts. And **`EventDetailsPopover` had no edit-mode entry**, which `reschedule-event` assumes — it gained `startInEditMode`.

24. **The notes reach the post-meeting pipeline** — `loadMeetingNotesForEvent` in `apps/server/src/services/meetings/meeting-notes.ts`, called from [handleExecuteMeeting.ts:94](apps/server/src/mastra/routeHandlers/event-execution/handleExecuteMeeting.ts)
    - Resolves `calendar_events.notes_document_id`, reads the markdown mirror, runs `parseNoteMarkers`. Returns `null` when there is no notes doc — the common case, and the pipeline must behave exactly as it does today when it is null.
    - Data after this step:
      ```json
      { "documentId": "5aa1…",
        "markdown": "# Cedar × Acme — discovery · Aug 27\n\n## Pain\n- [pain] manual CRM hygiene eats 6 hrs/week per rep <!--m:1f0c-->\n\n## Next steps\n- [task] send the SOC2 pack <!--m:88be-->\n- [demo] show the auto-draft queue on their own inbox <!--m:2b41-->\n",
        "markers": [
          { "markerId": "1f0c", "kind": "pain",  "text": "manual CRM hygiene eats 6 hrs/week per rep" },
          { "markerId": "88be", "kind": "task",  "text": "send the SOC2 pack" },
          { "markerId": "2b41", "kind": "demo",  "text": "show the auto-draft queue on their own inbox" } ] }
      ```

25. **The orchestrator treats notes as authoritative on intent** — [on-event-agent-execution-workflow.ts:243](apps/server/src/mastra/workflows/event-execution/on-event-agent-execution-workflow.ts) and [:302](apps/server/src/mastra/workflows/event-execution/on-event-agent-execution-workflow.ts)
    - `meetingNotes` is threaded through the step payload and rendered as a delimited `## Human notes from this meeting` block with one instruction: where the notes and the transcript disagree about what was *committed to*, the notes win — a transcript records what was said, the notes record what the rep decided.
    - `run-post-event-executor` at [orchestrator-dispatch-tools.ts:433](apps/server/src/mastra/tools/event-execution/orchestrator-dispatch-tools.ts) turns `task` / `next-step` markers into `userTasks` stamped with `(sourceDocumentId, sourceMarkerId)`, skipping markers that already produced a task; `demo` markers fold into the follow-up draft. `run-crm-updater` receives `pain` / `objection` / `competitor` markers as extraction candidates.

26. **Triage is written back into the live document** — `appendTriageSection` in `apps/server/src/services/meetings/meeting-notes.ts`
    - Appends `## Triage — {ISO}` via `applyUpdate` with `origin: 'agent'`, then sets `calendar_events.notes_triaged_at` and `notes_triage_run_id`.
    - Because the write goes through `applyUpdate`, a connected editor receives it over SSE through `useDocEvents` ([document.tsx:153](apps/mail/modules/documents/document.tsx)) with no refetch and no cursor jump. The document is not archived or overwritten — the user's notes and the agent's triage coexist. That is what makes it a live document rather than a report, and it is the one place the notes doc deliberately behaves *unlike* the brief.
    - Data appended:
      ```json
      { "section": "Triage — 2026-08-27T17:47:11Z",
        "tasksCreated": [ { "taskId": "t_91…", "markerId": "88be",
                            "description": "Send the SOC2 pack", "dueDate": "2026-08-28" } ],
        "crmFieldsUpdated": [ { "field": "pain_points", "markerId": "1f0c" } ],
        "draftsCreated": [ { "draftId": "d_04…", "channel": "email" } ] }
      ```

27. **The tab badge reflects meeting and prep state together** — `MeetingsTabTrigger` (new)
    - `before` → a countdown inside 60 minutes; `live` → a pulsing dot; `before | live` with `prepStatus.state === 'not-scheduled' | 'failed'` → an amber warning dot; `after` with notes present and `notes_triaged_at IS NULL` → an amber "needs triage" dot. Sourced from the same two queries the tab body uses, so no extra request.

28. **The prep agent stops being listed anywhere else in the deal** — `HIDDEN_AGENT_NAMES` at [AgentRow.tsx:65](apps/mail/modules/conversations/components/AgentRow.tsx) and a new `RELOCATED_AGENT_NAMES` in [resolveOpenDoc.ts:43](apps/mail/modules/conversations/components/files/resolveOpenDoc.ts)
    - **Agent rows:** `SYSTEM_AGENT_NAMES.MEETING_PREP` is added to the existing set at [AgentRow.tsx:65](apps/mail/modules/conversations/components/AgentRow.tsx), so `sortAgents` at [:72] drops it. One line, existing mechanism, no new concept.
    - **Files tab:** it is deliberately **not** added to the `HIDDEN_AGENT_NAMES` at [resolveOpenDoc.ts:43](apps/mail/modules/conversations/components/files/resolveOpenDoc.ts). That set means *never built* — `buildAgentDocs` at [:76] `continue`s past those agents entirely — and `resolveOpenDoc`'s `__meeting_prep__` branch at [:146] resolves **through** `agentDocs`. Adding it there would silently 404 every existing `?conversationId={id}/files/__meeting_prep__` notification link.
    - Instead a second, weaker set is introduced: `RELOCATED_AGENT_NAMES` — *built, resolvable, not listed*. `AgentDoc` gains `listed: boolean`, `buildAgentDocs` sets it false for these agents, and `FilesTab` filters the Agents folder on it. The distinction is the point: "this agent is plumbing" and "this agent has a better home" are different claims, and only the first justifies making its documents unreachable.
    - The sentinel itself is retargeted in the same change: `__meeting_prep__` now routes to the Meetings tab via `openMeeting` rather than opening a doc in Files, so an old deep-link lands somewhere better than it used to. **Ordering matters** — retarget first, relocate second; the reverse order ships a window where the link resolves to nothing.
    - Data after this step:
      ```json
      { "agentDocs": [
          { "rawName": "meeting-prep", "listed": false, "archives": [ … ] },
          { "rawName": "strategist",   "listed": true,  "archives": [] } ] }
      ```
    - Out of scope, deliberately: the **global** agent list (`HomeAgentsWidget`, `/agents`). The agent is relocated *within a deal*, where it now appears in its own tab; at the user level it is still an ordinary agent with an ordinary workspace, and hiding it there would remove the only surface for its user-scoped output.

### 3.3 Schema

Full schema:

```ts
// ─────────────────────────────────────────────────────────────────────────────
// Scope — the one parameter this design threads through four call sites.
// The server type ALREADY EXISTS at services/documents/convention-paths.ts:88.
// It is re-exported to the client rather than redeclared.
// ─────────────────────────────────────────────────────────────────────────────
export type AgentDocScope =
  | { type: 'conversation'; conversationId: string }
  | { type: 'user' };

// ─────────────────────────────────────────────────────────────────────────────
// DB — apps/server/src/db/crm-schema.ts
// calendar_events: existing table, three new columns (marked NEW).
// ─────────────────────────────────────────────────────────────────────────────
export const calendarEvents = pgTable(
  'calendar_events',
  {
    id: uuid('id').primaryKey().defaultRandom(),
    userId: text('user_id').notNull().references(() => user.id, { onDelete: 'cascade' }),

    googleEventId: text('google_event_id').notNull(),
    googleCalendarId: text('google_calendar_id').notNull(),
    iCalUID: text('ical_uid'),

    isRecurring: boolean('is_recurring').default(false).notNull(),
    recurringEventId: text('recurring_event_id'),
    recurrenceRule: text('recurrence_rule'),

    title: text('title').notNull(),
    description: text('description'),
    location: text('location'),

    startTime: timestamp('start_time').notNull(),
    endTime: timestamp('end_time').notNull(),
    isAllDay: boolean('is_all_day').default(false).notNull(),
    timezone: text('timezone'),

    eventStatus: text('event_status'),
    eventTransparency: text('event_transparency'),
    eventVisibility: text('event_visibility'),
    eventKind: text('event_kind'),

    organizerEmail: text('organizer_email'),
    organizerName: text('organizer_name'),
    organizerSelf: boolean('organizer_self').notNull().default(false),

    attendees: jsonb('attendees').$type<Array<{
      email: string;
      displayName?: string;
      responseStatus?: 'needsAction' | 'declined' | 'tentative' | 'accepted';
      self?: boolean;
      organizer?: boolean;
      optional?: boolean;
    }>>(),
    selfResponseStatus: text('self_response_status'),

    hangoutLink: text('hangout_link'),
    conferenceProvider: text('conference_provider'),
    conferenceUri: text('conference_uri'),

    // NO foreign key, deliberately — see the comment at crm-schema.ts:2019.
    conversationId: uuid('conversation_id'),

    // ── NEW ────────────────────────────────────────────────────────────────
    /**
     * This meeting's notes document, which lives inside the meeting-prep
     * agent's namespace for this deal:
     *   conversation/{conversationId}/agent-{agentId}/meetings/{googleEventId}/notes
     * NULL until the user opens the Meetings tab — creation is lazy, so most
     * rows stay NULL forever. ON DELETE SET NULL: deleting the doc must not
     * delete the meeting. The PATH is the address; this column is the
     * AUTHORITY — see project_merge_strands_conversation_docs for why a
     * path-only link is not enough.
     */
    notesDocumentId: uuid('notes_document_id')                         // NEW
      .references(() => documents.id, { onDelete: 'set null' }),
    /** When the post-meeting pipeline last triaged these notes. NULL = never. */
    notesTriagedAt: timestamp('notes_triaged_at'),                     // NEW
    /** agent_executions.run_id of that triage, for attribution + replay. */
    notesTriageRunId: text('notes_triage_run_id'),                     // NEW
    // ───────────────────────────────────────────────────────────────────────

    googleUpdatedAt: timestamp('google_updated_at'),
    googleSyncedAt: timestamp('google_synced_at'),

    createdAt: timestamp('created_at').notNull().defaultNow(),
    updatedAt: timestamp('updated_at').notNull().defaultNow(),
  },
  (table) => [
    uniqueIndex('unique_calendar_event_per_user').on(
      table.googleEventId, table.googleCalendarId, table.userId),
    index('idx_calendar_events_user_start').on(table.userId, table.startTime),
    index('idx_calendar_events_recurring_parent').on(table.recurringEventId),
    index('idx_calendar_events_is_recurring').on(table.isRecurring),
    index('idx_calendar_events_conversation_start').on(table.conversationId, table.startTime),
    // NEW — the "which meetings still need triage" badge query.
    index('idx_calendar_events_notes_untriaged')                       // NEW
      .on(table.userId, table.endTime)
      .where(sql`notes_document_id is not null and notes_triaged_at is null`),
  ],
);

// ─────────────────────────────────────────────────────────────────────────────
// DB — apps/server/src/db/aop-schema.ts
// agent_executions: NO new columns. dedupe_external_id already carries the
// event binding. It needs ONE new index so the prefix LIKE is not a scan.
// ─────────────────────────────────────────────────────────────────────────────
// agentExecutions (existing table) gains, in its index list:
//   // NEW — left-anchored prefix match on `{googleEventId}:before-meeting:%`.
//   // text_pattern_ops, NOT the default btree opclass: the default is collation-
//   // dependent and will not serve a LIKE 'prefix%' under a non-C collation.
//   index('idx_agent_executions_dedupe_prefix')                        // NEW
//     .on(sql`${t.dedupeExternalId} text_pattern_ops`)
//     .where(sql`${t.dedupeExternalId} IS NOT NULL`),
//
// user_tasks (existing table) gains two columns so a task traces to the note:
//   sourceMarkerId: text('source_marker_id'),                          // NEW
//   sourceDocumentId: uuid('source_document_id')                       // NEW
//     .references(() => documents.id, { onDelete: 'set null' }),
//   // (sourceDocumentId, sourceMarkerId) is the idempotency key=[redacted] re-runs
//   // check it before INSERT, so a second run creates nothing twice.

// ─────────────────────────────────────────────────────────────────────────────
// Logical document types — apps/server/src/services/documents/doc-type-registry.ts
// No documents.document_type migration: both store as DOCUMENT_TYPE.DOCUMENT,
// exactly as `deal_overview` already does.
// ─────────────────────────────────────────────────────────────────────────────
export const GET_DOC_TYPE = {
  AGENDA: 'agenda',
  CONVERSATION_AGENDA: 'conversation_agenda',
  DEAL_OVERVIEW: 'deal_overview',
  PLAYBOOK: 'playbook',
  PLAYBOOK_RESOURCE: 'playbook_resource',
  AGENT: 'agent',
  MEETING_NOTES: 'meeting_notes',                 // NEW
  MEETING_NOTE_TEMPLATE: 'meeting_note_template', // NEW
} as const;

/** NEW — conversation/{convId}/agent-{agentId}/meetings/{googleEventId}/notes */
const meetingNotesDef: DocTypeDef = {
  scopeType: 'conversation',
  storedDocumentType: DOCUMENT_TYPE.DOCUMENT,
  parsePath: (path: string) => {
    const m = /^conversation\/([^/]+)\/agent-([^/]+)\/meetings\/([^/]+)\/notes$/.exec(path);
    return m ? { scopeId: m[1]!, subPath: `${m[2]}/${m[3]}` } : null;  // agentId/googleEventId
  },
  seedFn: async (ctx: SeedContext) => Promise<ProseMirrorJson>,  // applies the template
  markdownToJson: (md: string) => ProseMirrorJson,
  jsonToMarkdown: (json: ProseMirrorJson) => string,
  // No `reconcile`: human-authored, nothing server-derived belongs in it, and a
  // reconciler would fight the user's cursor.
  buildSeedMetadata: ({ subPath }) => ({
    source: 'template',
    agentId: string,
    googleEventId: string,
    templateSlug: string | null,
    generatedAt: string,
  }),
};

/** NEW — user/meeting-note-templates/{slug} | organisation/meeting-note-templates/{slug} */
const meetingNoteTemplateDef: DocTypeDef = {
  scopeType: 'user',                       // org paths resolve via the generic arm
  storedDocumentType: DOCUMENT_TYPE.DOCUMENT,
  parsePath: (path: string) => ParsedPath | null,
  seedFn: async (ctx: SeedContext) => Promise<ProseMirrorJson>,  // BUILT_IN template
  buildSeedMetadata: () => ({ isDefault: boolean, slug: string, label: string }),
};

// ─────────────────────────────────────────────────────────────────────────────
// Prep status — apps/server/src/services/meetings/prep-status.ts (NEW).
// Dependency-free wire types, so apps/mail can `import type` them the way it
// already does for agent-workspace/types.ts.
// ─────────────────────────────────────────────────────────────────────────────
export const PREP_NOT_SCHEDULED_REASONS = [
  'no-aop',                    // conversation.aopId is null
  'no-before-meeting-config',  // playbook_manifest.beforeMeetingConfigs is empty
  'agent-disabled',            // the meeting-prep subagent's frontmatter says enabled:false
  'stage-mismatch',            // every config is stage-scoped to a stage this deal is not in
  'meeting-too-soon',          // startTime − minutes ≤ now for every config
  'unknown',                   // rows absent and no rule explains it — show, don't hide
] as const;
export type PrepNotScheduledReason = (typeof PREP_NOT_SCHEDULED_REASONS)[number];

export type MeetingPrepStatus =
  | { state: 'scheduled';     runId: string; agentId: string | null;
      scheduledFor: string; minutesBefore: number | null }
  | { state: 'running';       runId: string; agentId: string | null; startedAt: string }
  | { state: 'ready';         runId: string; agentId: string | null;
      completedAt: string; documentId: string | null }
  | { state: 'failed';        runId: string; agentId: string | null;
      failedAt: string; error: string | null }
  | { state: 'cancelled';     runId: string; agentId: string | null;
      cancelledAt: string; reason: string | null }
  | { state: 'not-scheduled'; reason: PrepNotScheduledReason;
      detail: { conversationStage: string | null; configuredStages: string[];
                configuredMinutes: number[] } };

/** One before-meeting config, as the manifest carries it. */
export interface BeforeMeetingConfig {
  minutes: number;
  stage: string | null;      // null = applies at every stage
  orgAopId: string | null;   // null = a user-level config
}

/**
 * The shared selection rule. Extracted from scheduleBeforeMeetingAutomations
 * (calendar-events.ts:278) and called by BOTH the scheduler and the status read,
 * so a chip explaining why prep is not scheduled cannot contradict the code that
 * decided not to schedule it.
 */
export function selectBeforeMeetingConfigs(
  configs: BeforeMeetingConfig[],
  conversationStage: string | null,
  isParticipantExecution: boolean,
): BeforeMeetingConfig[];

// ─────────────────────────────────────────────────────────────────────────────
// Note markers — apps/server/src/services/meetings/note-markers.ts (NEW),
// mirrored at apps/mail/modules/conversations/components/meeting/note-markers.ts
// ─────────────────────────────────────────────────────────────────────────────
export const NOTE_MARKER_KINDS = [
  'task',        // something I owe them  → userTasks row
  'next-step',   // the agreed next step  → conversation.nextSteps + userTasks row
  'pain',        // a stated pain point   → CRM field + demo planning
  'objection',   // a stated objection    → CRM field + follow-up framing
  'competitor',  // a named competitor    → CRM field
  'demo',        // show them this later  → follow-up draft + next meeting agenda
  'question',    // unanswered, chase it  → follow-up draft
  'decision',    // a decision was made   → conversation timeline note
] as const;
export type NoteMarkerKind = (typeof NOTE_MARKER_KINDS)[number];

export interface NoteMarker {
  markerId: string;          // 4–8 hex chars, stable across edits, from the HTML comment
  kind: NoteMarkerKind;
  text: string;              // the marker's inline content; the EDITOR strips markdown on write,
                             // the parser does not — it reports the line as written
  resolved: boolean;         // struck through in the editor; ignored by triage
}

/** `- [pain] text <!--m:1f0c-->` ⇄ NoteMarker. One regex, one test, both sides. */
export const NOTE_MARKER_RE =
  /^\s*-\s*\[(task|next-step|pain|objection|competitor|demo|question|decision)\]\s+(.*?)\s*<!--m:([0-9a-f]{4,8})(:resolved)?-->\s*$/;
export function parseNoteMarkers(markdown: string): NoteMarker[];
export function serializeNoteMarker(marker: NoteMarker): string;

// ─────────────────────────────────────────────────────────────────────────────
// Frontend types — apps/mail/modules/conversations/types/meeting.ts (NEW)
// ─────────────────────────────────────────────────────────────────────────────
export type MeetingPhase = 'before' | 'live' | 'after';

export interface ConversationMeeting {
  id: string;                            // calendar_events.id
  googleEventId: string;                 // what the dedup key is built from
  googleCalendarId: string;
  title: string;
  description: string | null;
  location: string | null;
  startTime: string;                     // ISO
  endTime: string;                       // ISO
  isAllDay: boolean;
  timezone: string | null;
  eventStatus: string | null;
  isRecurring: boolean;
  recurringEventId: string | null;
  organizerEmail: string | null;
  organizerSelf: boolean;
  attendees: Array<{
    email: string;
    displayName?: string;
    responseStatus?: 'needsAction' | 'declined' | 'tentative' | 'accepted';
    self?: boolean;
    organizer?: boolean;
    optional?: boolean;
  }>;
  selfResponseStatus: string | null;
  meetingLink: string | null;            // hangoutLink ?? conferenceUri
  notesDocumentId: string | null;        // NEW column, surfaced
  notesTriagedAt: string | null;         // NEW column, surfaced
  hasRecording: boolean;
}

export interface FocalMeeting {
  focal: ConversationMeeting | null;
  others: ConversationMeeting[];
  phase: MeetingPhase;
}

export interface MeetingActionContext {
  event: ConversationMeeting;
  conversationId: string;
  phase: MeetingPhase;
  isOrganizer: boolean;
  hasOtherAttendees: boolean;
  notesDocId: string | null;
  editor: Editor | null;                 // the notes editor, for insert-at-cursor actions
}

export interface MeetingAction {
  id: string;
  label: string;
  icon: LucideIcon;
  phases: MeetingPhase[];
  destructive?: boolean;
  enabled: (ctx: MeetingActionContext) => boolean;
  run: (ctx: MeetingActionContext) => void | Promise<void>;
}

/** One archived prep, as the Past Meeting Preps chips render it. */
export interface PastMeetingPrep {
  documentId: string;
  /** The archive path's trailing ISO date — the RUN date, not the meeting date. */
  date: string;
  /** `Aug 13` alone, or `Aug 13 · Kickoff` once correlated to a meeting. */
  label: string;
  /** Exact from metadata.googleEventId when stamped; inferred by date otherwise; null when neither. */
  googleEventId: string | null;
  /** How the correlation was reached — drives nothing visual, but makes the fallback auditable. */
  correlation: 'stamped' | 'date-match' | 'nearest-48h' | 'none';
}

/** One row of the Previous meetings log. A PAST calendar event, not an archive. */
export interface MeetingLogEntry {
  id: string;                            // calendar_events.id
  googleEventId: string;
  title: string;
  startTime: string;                     // ISO
  endTime: string;                       // ISO
  attendeeCount: number;
  attendeePreview: Array<{ email: string; displayName?: string }>;  // first 5, for the stack
  selfResponseStatus: string | null;
  notesDocumentId: string | null;        // → [Notes]
  prepArchiveDocumentId: string | null;  // → [Prep], filled by correlateArchives on the client
  hasRecording: boolean;                 // → [▶]
}

export interface MeetingNoteTemplate {
  id: string;                            // documents.id
  slug: string;
  label: string;
  scope: 'user' | 'org';
  isDefault: boolean;
}

// ─────────────────────────────────────────────────────────────────────────────
// Changed component contracts — apps/mail/modules/agents/
// Every new prop is OPTIONAL with a default that reproduces today's behaviour,
// so /agents/:agentId renders byte-identically.
// ─────────────────────────────────────────────────────────────────────────────
export function agentNamespacePath(
  agentId: string,
  scope?: AgentDocScope,                 // NEW — defaults to { type: 'user' }
): string;

export function agentMemoryDirPath(
  agentId: string,
  scope?: AgentDocScope,                 // NEW — defaults to { type: 'user' }
): string;

interface AgentFileBrowserProps {
  rootPath: string;
  scope?: AgentDocScope;                 // NEW — defaults to { type: 'user' }
  includeNode?: (node: TreeNode) => boolean;
  emptyState: ReactNode;
  className?: string;
}

interface AgentOutputTabProps {
  agentId: string;
  scope?: AgentDocScope;                 // NEW
  leadingPane?: ReactNode;               // NEW — rendered above the file browser
  outputs?: AgentOutputs;
  isLoading?: boolean;
  className?: string;
}

interface AgentViewProps {
  agentId: string;
  scope?: AgentDocScope;                 // NEW
  chrome?: 'page' | 'embedded';          // NEW — 'embedded' drops gutter + h-full only
  outputLeadingPane?: ReactNode;         // NEW — forwarded to AgentOutputTab
  tab?: AgentTab;                        // NEW — controlled tab, for the embedded case
  onTabChange?: (next: AgentTab) => void;// NEW
  className?: string;
}

// ─────────────────────────────────────────────────────────────────────────────
// Agent display — apps/mail/modules/conversations/components/files/resolveOpenDoc.ts
//
// TWO sets, because "plumbing" and "has a better home" are different claims and
// only the first justifies making an agent's documents unreachable.
// ─────────────────────────────────────────────────────────────────────────────
/** Never built. buildAgentDocs skips these entirely; their docs are unreachable. */
const HIDDEN_AGENT_NAMES: ReadonlySet<string> = new Set([
  SYSTEM_AGENT_NAMES.CRM_UPDATER,
  SYSTEM_AGENT_NAMES.POST_EVENT_TASK_EXECUTOR,
  SYSTEM_AGENT_NAMES.TASK_AGGREGATOR,
]);

/**
 * NEW. Built and resolvable, simply not LISTED in the Files tab's Agents folder —
 * the agent has a first-class home elsewhere in the deal.
 *
 * meeting-prep must stay built: resolveOpenDoc's `__meeting_prep__` branch
 * resolves through agentDocs, so hiding it the other way 404s every existing
 * notification deep-link.
 */
const RELOCATED_AGENT_NAMES: ReadonlySet<string> = new Set([   // NEW
  SYSTEM_AGENT_NAMES.MEETING_PREP,
]);

export type AgentDoc = {
  id: string;
  agentId: string;
  rawName: string;
  name: string;
  doc: Doc;
  files: Doc[];
  archives: Doc[];
  listed: boolean;                       // NEW — false for RELOCATED_AGENT_NAMES
};

// AgentRow.tsx:65 — the conversation agent-row set, one line added:
// const HIDDEN_AGENT_NAMES = new Set<string>([
//   SYSTEM_AGENT_NAMES.POST_EVENT_TASK_EXECUTOR,
//   SYSTEM_AGENT_NAMES.MEETING_PREP,                                        // NEW
// ]);

// ─────────────────────────────────────────────────────────────────────────────
// Archive metadata — stamped SERVER-SIDE by writeDocument, not by the agent.
// Closes agent-workspace gap G2 for archive rows.
// ─────────────────────────────────────────────────────────────────────────────
interface AgentArchiveMetadata {
  runId: string | null;                  // NEW — from the ambient execution scope
  googleEventId: string | null;          // NEW — parsed off the run's dedupe_external_id
                                         //       when it matches the before-meeting shape
  archivedAt: string;                    // NEW — ISO, the write time (≠ the path's date)
}

// ─────────────────────────────────────────────────────────────────────────────
// Store — apps/mail/modules/conversations/slice/conversationsSlice.ts
// ─────────────────────────────────────────────────────────────────────────────
export const CONVERSATION_TAB_KEYS = [
  'strategicOverview',
  'meetings',                            // NEW — second, right after Overview.
                                         // Plural: the tab is one focal meeting on top
                                         // and the deal's whole meeting history below.
                                         // Deep-link `meetings/{eventId}` — a collection
                                         // addressed at one member, like `files/{docId}`.
  'timeline',
  'inbox',
  'files',
  'crm',
  'directory',
] as const;

// ConversationsSlice gains:
//   focalMeetingId: string | null;                             // NEW — googleEventId
//   setFocalMeetingId: (googleEventId: string | null) => void; // NEW
//   meetingAgentTab: AgentTab;                                 // NEW — persisted
//   setMeetingAgentTab: (tab: AgentTab) => void;               // NEW
//   meetingPrepCollapsed: Record<MeetingPhase, boolean>;       // NEW — persisted
//   setMeetingPrepCollapsed: (phase: MeetingPhase, v: boolean) => void; // NEW
// `openConversation` clears focalMeetingId, mirroring conversationOpenFile.

// ─────────────────────────────────────────────────────────────────────────────
// tRPC — new + changed procedures
// ─────────────────────────────────────────────────────────────────────────────
// crm.ts (NEW)
listConversationMeetings: privateProcedure
  .input(z.object({
    conversationId: z.string().uuid(),
    windowDays: z.number().int().min(1).max(365).default(30),
  }))
  .query(async ({ ctx, input }): Promise<ConversationMeeting[]> => { /* … */ });

/**
 * NEW — the Previous meetings log. Separate from listConversationMeetings on
 * purpose: that one answers "what is around right now" over a ±30-day window and
 * feeds the strip and the picker; this one answers "what have we had" over the
 * whole history and must be paged, because a two-year deal has hundreds of rows.
 */
listConversationMeetingLog: privateProcedure
  .input(z.object({
    conversationId: z.string().uuid(),
    /** ISO cursor — return meetings starting strictly before this. */
    before: z.string().datetime().optional(),
    limit: z.number().int().min(1).max(50).default(10),
  }))
  .query(async ({ ctx, input }): Promise<{
    rows: MeetingLogEntry[];
    nextCursor: string | null;
  }> => { /* endTime < now(), ORDER BY start_time DESC, LIMIT limit + 1 */ });

// meetings.ts (NEW router)
getPrepStatus: privateProcedure
  .input(z.object({
    conversationId: z.string().uuid(),
    googleEventId: z.string(),
  }))
  .query(async ({ ctx, input }): Promise<MeetingPrepStatus> => { /* … */ });

// agent.ts (CHANGED — both inputs gain an optional conversation)
getOutputs: privateProcedure
  .input(AgentIdInput.extend({ conversationId: z.string().uuid().optional() }))  // NEW
  .query(async ({ ctx, input }): Promise<AgentOutputs> => { /* … */ });
getRuns: privateProcedure
  .input(AgentIdInput.extend({
    kind: z.enum(['executions', 'chats']).default('executions'),
    limit: z.number().min(1).max(100).default(25),
    conversationId: z.string().uuid().optional(),                                // NEW
  }))
  .query(async ({ ctx, input }) => { /* … */ });
// agent.runNow ALREADY takes { agentId, conversationId? } — unchanged.
```

Relationship diagram:

```text
  ┌───────────────────────────┐
  │ user                      │
  │  id (PK) text             │
  └─────────┬─────────────────┘
            │ 1:N  user_id
            ▼
  ┌──────────────────────────────────────────────────────────┐
  │ calendar_events                                          │
  │  id (PK) uuid                                            │
  │  user_id ──FK──► user.id            (ON DELETE cascade)  │
  │  google_event_id text  ┐ unique with calendar + user     │
  │  google_calendar_id    ┘   └── ALSO the prefix of every  │
  │                                before-meeting dedup key  │
  │  conversation_id uuid  ··soft ref, NO FK (see :2019)··►  │
  │  start_time / end_time timestamp                         │
  │  attendees jsonb  ▼ contains                             │
  │      [{ email, displayName, responseStatus, self,        │
  │         organizer, optional }]                           │
  │  self_response_status text                               │
  │  notes_document_id ──FK──► documents.id  (SET NULL) NEW  │
  │  notes_triaged_at timestamp                         NEW  │
  │  notes_triage_run_id ··soft ref··► agent_executions  NEW │
  └──┬────────────────────┬──────────────────┬───────────────┘
     │ 0:1                │ soft N:1         │ 1:N  (by dedupe_external_id prefix)
     │ notes_document_id  │ conversation_id  │
     ▼                    ▼                  ▼
┌─────────────────┐ ┌──────────────────┐ ┌──────────────────────────────────────┐
│ documents       │ │ crm_conversations│ │ agent_executions                     │
│  id (PK) uuid   │ │  id (PK) uuid    │ │  run_id (PK) text                    │
│  path text      │ │  aop_id text     │ │  conversation_id ──FK──► crm_conv.id │
│  document_type  │ │  status text     │ │  agent_id text  (no FK, by design)   │
│    'document'   │ │   └─ the stage   │ │  status ∈ pending|executing|         │
│  content (Y.js) │ │      the stage-  │ │           completed|canceled|final|   │
│  markdown       │ │      match rule  │ │           failed                      │
│  metadata jsonb │ │      compares to │ │  scheduled_for timestamp             │
│    ▼ contains   │ └──────────────────┘ │  source 'playbook-before-meeting'    │
│   { source,     │                      │  dedupe_external_id text             │
│     agentId,    │                      │    = `{googleEventId}:before-meeting │
│     googleEventId,                     │        :playbook:{aopId}:{stage}     │
│     templateSlug}                      │        :{minutes}`                   │
│  version int    │                      │    └── written  calendar-events:471  │
└──┬──────────────┘                      │        matched  calendar-events:426  │
   │ 1:N source_document_id              │        READ BY  prep-status.ts  NEW  │
   ▼                                     └──────────────────────────────────────┘
┌───────────────────────────────────────────────┐
│ user_tasks                                    │
│  id (PK) uuid                                 │
│  conversation_id uuid                         │
│  description text · due_date · status         │
│  source_document_id ──FK──► documents.id  NEW │
│  source_marker_id text                    NEW │
│    └─ the pair is the re-triage idempotency   │
│       key=[redacted] before every INSERT        │
└───────────────────────────────────────────────┘

  ── the document namespace, which is the whole point of the change ────────────
  conversation/{convId}/agent-{agentId}/            ← minted by agentNamespacePath
    overview                                          the current prep brief
    archives/{date}                                   previous briefs
        └── rendered as the "Past Meeting Preps" button strip. metadata gains
            { runId, googleEventId, archivedAt }              NEW, server-stamped
            so an archive names the meeting it prepped instead of only a date.
    meetings/{googleEventId}/notes            NEW     THIS meeting's notes
        └── inside the AGENT's folder, so it is the agent's output by
            construction: listed by getAgentOutputs with no special case,
            shown in the workspace tree beside overview/ and archives/, and
            attributed through document_updates.actor_agent_id.

  user/agent-{agentId}/                             ← unchanged, still the default
    overview · archives/{date} · memory/*.md

  user/meeting-note-templates/{slug}          NEW   ┐ user shadows org
  organisation/meeting-note-templates/{slug}  NEW   ┘ on slug collision

  ── the marker, which is not a row anywhere ───────────────────────────────────
  A NoteMarker lives ONLY inside documents.markdown / the Y.Doc, as
      - [pain] text <!--m:1f0c-->
  parsed on read by parseNoteMarkers(). Deliberately not a table: the user must
  be able to retype, reorder, delete and undo freely, and a row would
  immediately disagree with the document. The only durable trace a marker
  leaves is user_tasks.source_marker_id, written when triage acts on it.
```

## 4) Implementation phases

### Phase 1 — Make the agent workspace scope-aware

**Goal:** `AgentView` can render an agent at a conversation instead of at the user, with `/agents/:agentId` unchanged.

- [x] Re-export `AgentDocScope` from [apps/server/src/services/documents/convention-paths.ts:88](apps/server/src/services/documents/convention-paths.ts) through the dependency-free `agent-workspace/types.ts` so `apps/mail` can `import type` it.
- [x] Add the optional `scope` parameter to `agentNamespacePath` and `agentMemoryDirPath` in [agent-paths.ts:13](apps/mail/modules/agents/utils/agent-paths.ts), defaulting to `{ type: 'user' }`, and correct the module comment that calls the namespace user-scoped.
- [x] Add the optional `scope` prop to `AgentFileBrowser` at [AgentFileBrowser.tsx:42](apps/mail/modules/agents/components/AgentFileBrowser.tsx), passing `{ type: 'conversation', id: conversationId }` to `useFileTree` when set.
- [x] Add `scope` to `getAgentOutputs` params at [outputs.ts:37](apps/server/src/services/agent-workspace/outputs.ts); use `agentNamespacePath(scope, agentId)`, drop the `documents.userId` predicate under conversation scope, and return `inConversations.count = 0` there.
- [x] Add `conversationId` to the `agent.getOutputs` input at [agent.ts:323](apps/server/src/trpc/routes/agent.ts) with a conversation-membership assertion alongside `requireAgent`.
- [x] Add `conversationId` to the `agent.getRuns` input at [agent.ts:343](apps/server/src/trpc/routes/agent.ts) and an optional `conversation_id` filter in `getAgentRuns`.
- [x] Add `scope`, `chrome`, `outputLeadingPane`, `tab` and `onTabChange` props to `AgentView` at [AgentView.tsx:44](apps/mail/modules/agents/components/AgentView.tsx); `chrome: 'embedded'` drops only `AgentBackGutter` and the `h-full` wrapper.
- [x] Add `scope` and `leadingPane` to `AgentOutputTab` at [AgentOutputTab.tsx:91](apps/mail/modules/agents/components/AgentOutputTab.tsx).

**Tests:**

- [x] `apps/mail/tests/modules/agents/agent-paths.test.ts` — the default is `user/agent-{id}`; conversation scope mints `conversation/{c}/agent-{id}`; memory path follows the scope.
- [x] `apps/server/src/services/agent-workspace/__tests__/outputs-scoped.test.ts` — conversation scope lists the deal namespace including a co-editor's rows; user scope is byte-identical to today.
- [x] `apps/mail/modules/agents/__tests__/AgentViewLayout.test.tsx` — extend: `chrome: 'embedded'` drops the gutter and keeps every padding and the 75ch column.
- [x] `apps/server/src/trpc/routes/__tests__/agent-scoped-reads.test.ts` — an agent grant without deal membership is refused.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/agent-workspace/__tests__/outputs-scoped.test.ts`

### Phase 2 — The Meetings tab mounts that workspace

**Goal:** A `meetings` tab renders the meeting-prep agent's workspace for this deal, with a strip naming the focal meeting.

- [x] Add `listConversationMeetings` to [apps/server/src/trpc/routes/crm.ts](apps/server/src/trpc/routes/crm.ts) returning the `ConversationMeeting` shape, ordered by `startTime`, not collapsing recurring instances.
- [x] Add `apps/mail/modules/conversations/types/meeting.ts` with `MeetingPhase`, `ConversationMeeting`, `FocalMeeting`.
- [x] Add `'meetings'` to `CONVERSATION_TAB_KEYS` at [conversationsSlice.ts:38](apps/mail/modules/conversations/slice/conversationsSlice.ts), after `strategicOverview`.
- [x] Add `focalMeetingId`, `setFocalMeetingId` and `meetingAgentTab` to the conversations slice; clear `focalMeetingId` inside `openConversation`.
- [x] Teach `normalizeConversationSection` at [conversationsSlice.ts:64](apps/mail/modules/conversations/slice/conversationsSlice.ts) to fold `meetings/{eventId}` into `meetings`, with a `parseMeetingSection` helper beside `parseAgentSection`.
- [x] Add `meetings: 'Meetings'` to `TAB_LABELS` at [ConversationTabs.tsx:12](apps/mail/modules/conversations/components/ConversationTabs.tsx).
- [x] Add `apps/mail/modules/conversations/hooks/use-focal-meeting.ts` implementing §3.2 step 8 with a 30s tick.
- [x] Add `apps/mail/modules/conversations/hooks/use-meeting-prep-agent.ts` resolving the `meeting-prep` agent id by NAME via `SYSTEM_AGENT_NAMES.MEETING_PREP`. (AMENDED: the plan said "for the conversation's AOP"; `agent.list()` is user+org scoped and carries no AOP filter.)
- [x] Add `MeetingStrip.tsx` — title, time, relative countdown, attendee stack, meeting picker writing `setFocalMeetingId`.
- [x] Add `MeetingsTab.tsx` rendering `MeetingStrip` above `<AgentView agentId={prepAgentId} scope={{type:'conversation', conversationId}} chrome="embedded" />`.
- [x] Add the `case 'meetings'` arm to [ConversationTabBody.tsx:17](apps/mail/modules/conversations/components/ConversationTabBody.tsx).
- [x] Render an explicit empty state when the deal has no meetings and no prep archives, and hide the tab trigger entirely in that case.

**Tests:**

- [x] `apps/mail/tests/modules/conversations/use-focal-meeting.test.ts` — phase boundaries at `start−5m`, `start`, `end`, `end+15m`; explicit `focalMeetingId` wins; the no-upcoming fallbacks.
- [x] `apps/mail/tests/modules/conversations/meetingsTab.test.tsx` — the tab mounts `AgentView` with conversation scope and the prep agent's id.
- [x] `apps/server/src/trpc/routes/__tests__/crm-list-conversation-meetings.test.ts` — window filtering, ordering, recurring instances not collapsed, membership rejection.
- [x] `timeout 300 pnpm --filter @zero/mail exec jest --config jest.config.cjs --no-coverage --ci tests/modules/conversations/use-focal-meeting.test.ts`

### Phase 3 — Prep status: has the agent fired for this meeting?

**Goal:** The strip states plainly whether prep has run for the focal meeting, and when it has not, why — with a button that fixes it.

- [x] Extract `selectBeforeMeetingConfigs(configs, conversationStage, isParticipantExecution)` from [calendar-events.ts:278](apps/server/src/services/crm/calendar-events.ts) into `apps/server/src/services/crm/before-meeting-configs.ts`, and call it from the scheduler so there is one rule, not two.
- [x] Add the `idx_agent_executions_dedupe_prefix` `text_pattern_ops` index to `agentExecutions` in [apps/server/src/db/aop-schema.ts:1581](apps/server/src/db/aop-schema.ts). Measured, not assumed: DB collation is `en_US.UTF-8`, and `EXPLAIN ANALYZE` of the real query showed a `BitmapAnd` using the plain index only as an `IS NOT NULL` filter — **5,202 rows removed by filter, 5,067 heap blocks, 108ms**. The default opclass genuinely cannot serve the prefix `LIKE`.
- [x] Add `apps/server/src/services/meetings/prep-status.ts` with `MeetingPrepStatus`, `PREP_NOT_SCHEDULED_REASONS`, and `getMeetingPrepStatus`, matching on the `{googleEventId}:before-meeting:playbook:%` prefix and applying the row precedence from §3.2 step 10.
- [x] Implement the not-scheduled diagnosis using `selectBeforeMeetingConfigs`, in the reason order from §3.2 step 11.
- [x] Add an `apps/server/src/trpc/routes/meetings.ts` router with `getPrepStatus`, mounted on the app router.
- [x] Add `apps/mail/modules/conversations/hooks/use-meeting-prep-status.ts`, polling while the state is `scheduled` or `running`.
- [x] Add `PrepStatusChip.tsx` rendering one line per state, amber for `not-scheduled` / `failed` / `cancelled`, with the reason in plain words and a link to the workspace's Config tab.
- [x] Wire **Run prep now** / **Re-run** to `agent.runNow({ agentId, conversationId })` at [agent.ts:394](apps/server/src/trpc/routes/agent.ts), with an optimistic move to `running`.
- [x] Add `MeetingsTabTrigger.tsx` with the countdown / live-dot / amber prep-warning badge and mount it in [ConversationTabs.tsx:25](apps/mail/modules/conversations/components/ConversationTabs.tsx).

**Tests:**

- [x] `apps/server/src/services/crm/__tests__/before-meeting-configs.test.ts` — stage normalization (`*`→`_`, case), null-stage configs always selected, the participant `orgAopId === null` filter.
- [x] `apps/server/src/services/meetings/__tests__/prep-status.test.ts` — one row per status maps to the right state; a `canceled` row beside a `pending` row yields `scheduled`; a `canceled` row alone yields `cancelled`; every not-scheduled reason with the right `detail`.
- [x] `apps/server/src/services/meetings/__tests__/prep-status-parity.test.ts` — for a matrix of manifests and stages, "the scheduler scheduled nothing" and "the status read says not-scheduled" agree, and disagree for none.
- [x] `apps/mail/tests/modules/conversations/prepStatusChip.test.tsx` — each state renders its line; Run-now appears exactly on `not-scheduled | failed | cancelled`.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/meetings/__tests__/ src/services/crm/__tests__/before-meeting-configs.test.ts`

### Phase 4 — The notes document, inside the agent's namespace

**Goal:** Opening the Meetings tab provisions a notes document in the meeting-prep agent's folder for this deal, and the Output tab leads with it above the brief.

- [x] Add `notes_document_id`, `notes_triaged_at`, `notes_triage_run_id` and the partial untriaged index to `calendarEvents` at [crm-schema.ts:1960](apps/server/src/db/crm-schema.ts), with the migration.
- [x] Add `meetingNotesPath(conversationId, agentId, googleEventId)` to [convention-paths.ts:86](apps/server/src/services/documents/convention-paths.ts), built from `agentNamespacePath` rather than hand-assembled.
- [x] Add `buildDocPath.meetingNotes(conversationId, agentId, googleEventId)` to [buildDocPath.ts](apps/mail/modules/files/store/buildDocPath.ts).
- [x] Add `MEETING_NOTES` to `GET_DOC_TYPE` and register `meetingNotesDef` in [doc-type-registry.ts:56](apps/server/src/services/documents/doc-type-registry.ts) — `parsePath`, `markdownToJson`, `jsonToMarkdown`, a placeholder `seedFn` emitting the built-in skeleton, no `reconcile`.
- [x] Add `linkNotesDocument` to `apps/server/src/services/meetings/meeting-notes.ts` and call it from the `getDoc` create path for `meeting_notes`.
- [x] Add `apps/mail/modules/conversations/hooks/use-meeting-notes-doc.ts` using `documents.getDoc` with `omitContent: true`.
- [x] Add `MeetingNotesPane.tsx` mounting `Document` with `useRichTextExtensions()` only, for now.
- [x] Add `MeetingPrepPane.tsx` mounting `Document` over the agent's `overview`, collapsible, with per-phase persisted collapse state; show a "Run meeting prep" affordance when the brief does not exist.
- [x] Pass both panes to `AgentView` as `outputLeadingPane` from `MeetingsTab`.

**Tests:**

- [x] `apps/server/src/services/documents/__tests__/meeting-notes-doc-type.test.ts` — `parsePath` accepts the canonical shape, rejects a path missing the `agent-` segment, extracts `agentId/googleEventId` as `subPath`; markdown round-trip.
- [x] `apps/server/src/services/meetings/__tests__/meeting-notes-link.test.ts` — first create sets the FK, a second does not overwrite, a wrong-user call is a no-op.
- [x] `apps/server/src/services/agent-workspace/__tests__/outputs-scoped.test.ts` — extend: a notes doc appears in the conversation-scoped `owned` list with no special case.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/meetings/__tests__/meeting-notes-link.test.ts`

### Phase 5 — Past Meeting Preps and the previous-meetings log

**Goal:** Below the brief, a button strip over the archives folder and a paged log of every meeting the deal has had, each row reaching its own notes and its own archived prep.

- [x] Stamp `metadata.runId`, `metadata.googleEventId` and `metadata.archivedAt` server-side in `writeDocument` at [documents/index.ts:361](apps/server/src/services/documents/index.ts) for any path matching `…/agent-{id}/archives/{date}`, parsing the event id off the run's `dedupe_external_id` when it matches the before-meeting shape.
- [x] Add `listConversationMeetingLog` to [apps/server/src/trpc/routes/crm.ts](apps/server/src/trpc/routes/crm.ts) — `endTime < now()`, `ORDER BY start_time DESC`, `LIMIT limit + 1` for the cursor, returning `MeetingLogEntry[]` plus `nextCursor`.
- [x] Add `PastMeetingPrep` and `MeetingLogEntry` to `apps/mail/modules/conversations/types/meeting.ts`.
- [x] Add `correlate-archives.ts` implementing the stamped → date-match → nearest-48h → none ladder from §3.2 step 18, returning the `correlation` provenance on every entry.
- [x] Add `PastMeetingPrepsSection.tsx` — a wrapped strip of date chips over `AgentDoc.archives`, newest first, collapsed past eight behind `+ N more`.
- [x] Wire a chip click to open that archive **in the prep pane**, with a "viewing an archived prep" bar and a way back to the current brief.
- [x] Add `PreviousMeetingsLog.tsx` — a paged row list with the attendee stack, RSVP outcome, and the `[Notes]` / `[Prep]` / `[▶]` affordances, each rendered only when its target exists.
- [x] Add both sections to the `outputLeadingPane` beneath `MeetingPrepPane`, in that order.
- [x] Render an empty state for each section independently — a deal with meetings but no preps must show the log and say so.

**Tests:**

- [x] `apps/mail/tests/modules/conversations/correlate-archives.test.ts` — a stamped archive wins over a date match; an exact local-date match; the 48h fallback for a 12h-before prep archived the previous day; an uncorrelated archive is returned with `correlation: 'none'` and is not dropped.
- [x] `apps/server/src/services/documents/__tests__/archive-metadata-stamp.test.ts` — an archive write inside a before-meeting run carries the event id; a write outside one carries `runId` and a null event id; a non-archive path is untouched.
- [x] `apps/server/src/trpc/routes/__tests__/crm-meeting-log.test.ts` — pagination boundary, `nextCursor` null on the last page, recurring instances listed individually, membership rejection.
- [x] `apps/mail/tests/modules/conversations/pastMeetingPreps.test.tsx` — chips render newest-first, `+ N more` past eight, a click opens the archive in the prep pane rather than navigating.
- [x] `timeout 300 pnpm --filter @zero/mail exec jest --config jest.config.cjs --no-coverage --ci tests/modules/conversations/correlate-archives.test.ts`

### Phase 6 — Note markers and in-editor invocations

**Goal:** The notes editor captures structured markers, real task lines, and attendee mentions, round-tripping through markdown losslessly.

- [x] Add `apps/server/src/services/meetings/note-markers.ts` with `NOTE_MARKER_KINDS`, `NOTE_MARKER_RE`, `parseNoteMarkers`, `serializeNoteMarker`.
- [x] Re-export the module to `apps/mail` via one `exports` entry in apps/server/package.json (`"./meetings/note-markers"`) and a thin `apps/mail/modules/conversations/components/meeting/note-markers.ts` that re-exports it — NOT a mirrored copy. Add an identity test (`NOTE_MARKER_RE === server.NOTE_MARKER_RE`) proving the export map resolves in the frontend toolchain.
- [x] Add `NoteMarkerNode.tsx` — inline TipTap node with `kind` / `markerId` / `resolved`, a coloured chip, kind-switch and resolve affordances, serializing via `serializeNoteMarker`.
- [x] Re-mint `markerId` on PASTE in `NoteMarkerNode`. Copy-pasting a marker line is an ordinary editing action and otherwise produces two markers sharing one id, of which triage would only ever act on the first.
- [x] Add one `SlashCommandItem` per kind in `meeting-slash-commands.ts`.
- [x] Add `/ask-cedar` and `/draft-followup` slash commands to the same file.
- [x] Mount `AgendaTaskNode` from [AgendaTaskNode.tsx:980](apps/mail/modules/agentCanvas/extensions/AgendaTaskNode.tsx) so `[]` produces a real invocable task bound to this conversation.
- [x] Add `attendeeSuggestion` — an `@` mention source built from `event.attendees`, modelled on `colleagueSuggestion` in [OverviewDocTab.tsx:157](apps/mail/modules/conversations/components/OverviewDocTab.tsx).
- [x] Wire all of the above into `MeetingNotesPane` via `extraExtensions` / `extraSlashCommands`.

**Tests:**

- [x] `apps/server/src/services/meetings/__tests__/note-markers.test.ts` — round-trip per kind; `resolved` markers; markers in nested lists; text containing `]` and `<!--`; a line that looks like a marker but carries no id.
- [x] `apps/mail/tests/modules/conversations/noteMarkerNode.test.tsx` — insert via slash, switch kind, resolve, markdown output.
- [x] `apps/mail/tests/modules/conversations/note-marker-parity.test.ts` — client and server regex are the SAME object (identity, not parity).
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/meetings/__tests__/note-markers.test.ts`

### Phase 7 — Templates

**Goal:** Notes are seeded from a template, and the user can pick, apply, edit, and save templates.

- [x] Add `apps/server/src/services/meetings/note-templates.ts` with `BUILT_IN_MEETING_NOTE_TEMPLATES` (discovery, demo, follow-up, internal) and `resolveNoteTemplate` applying user → org → built-in precedence.
- [x] Register `meetingNoteTemplateDef` and `MEETING_NOTE_TEMPLATE` in the doc-type registry, plus the two template roots in [convention-paths.ts:86](apps/server/src/services/documents/convention-paths.ts).
- [x] Replace `meetingNotesDef.seedFn`'s placeholder with real template application, prepending `# {title} — {date}` and writing `metadata.templateSlug`.
- [x] Add `resolveNoteTemplate` to `SeedFetchers` at [doc-type-registry.ts:65](apps/server/src/services/documents/doc-type-registry.ts) and implement it in the `getDoc` wiring at [documents.ts:693](apps/server/src/trpc/routes/documents.ts).
- [x] Add `MeetingTemplatePicker.tsx` listing user + org templates via `documents.list`, appending at the caret on a non-empty doc.
- [x] Add a `/template` slash command opening the picker.
- [x] Add "Save as template" to the notes pane overflow menu, writing `user/meeting-note-templates/{slug}`.
- [x] Offer the four built-in templates from `meetings.listNoteTemplates` rather than seeding them as documents. (AMENDED: seeding meant four rows per user forever, a migration to place them, and a second copy of the text that drifts from the constant the SEED still applies. Returning them as `scope: 'builtin'` rows gives the same picker with none of that; a stored template of the same slug shadows the built-in, so editing one still works — it just creates the row on first save instead of at signup.)

**Tests:**

- [x] `apps/server/src/services/meetings/__tests__/note-templates.test.ts` — user shadows org shadows built-in; a missing default falls through; slug collision resolution.
- [x] `apps/server/src/services/documents/__tests__/meeting-notes-seed.test.ts` — a first `getDoc` returns template-seeded JSON with the title heading and correct metadata.
- [x] `apps/mail/tests/modules/conversations/meetingTemplatePicker.test.tsx` — apply-into-empty replaces, apply-into-non-empty appends.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/meetings/__tests__/note-templates.test.ts`

### Phase 8 — The phase-aware action row

**Goal:** Every meeting action sits in the strip, correct for the phase, delegating to existing handlers.

- [x] Lift the join logic from [EventDetailsPopover.tsx:2131](apps/mail/modules/calendar/components/EventDetailsPopover.tsx) into `modules/calendar/utils/joinMeeting.ts` and call it from both sites.
- [x] Lift the RSVP mutation from [EventDetailsPopover.tsx:1850](apps/mail/modules/calendar/components/EventDetailsPopover.tsx) into `modules/calendar/hooks/use-event-rsvp.ts` and call it from both sites.
- [x] Add `meeting-actions.ts` with the `MEETING_ACTIONS` registry and the per-phase sets from §3.2 step 23.
- [x] Add `MeetingActionRow.tsx` rendering the enabled actions for the phase, overflowing past four into a dropdown, destructive actions styled and confirmed.
- [x] Wire `reschedule-event` to open `EventDetailsPopover` in edit mode anchored to the row, reusing its notify/don't-notify confirm at [EventDetailsPopover.tsx:1651](apps/mail/modules/calendar/components/EventDetailsPopover.tsx).
- [x] Wire `add-task` to insert an `agendaTask` node at the notes cursor, threading `editor` through `MeetingActionContext`.

**Tests:**

- [x] `apps/mail/tests/modules/conversations/meeting-actions.test.ts` — the set per phase; `cancel` disabled for a non-organizer; `join` disabled with no link; `no-show` disabled on an all-day event.
- [x] `apps/mail/tests/modules/conversations/meetingActionRow.test.tsx` — overflow past four, destructive confirm, RSVP writes through the shared hook.
- [x] `timeout 300 pnpm --filter @zero/mail exec jest --config jest.config.cjs --no-coverage --ci tests/modules/conversations/meeting-actions.test.ts`

### Phase 9 — Post-meeting triage reads the notes

**Goal:** The post-event pipeline receives the human notes, acts on the markers, and writes its triage back into the same live document.

- [x] Add `loadMeetingNotesForEvent` to `apps/server/src/services/meetings/meeting-notes.ts`, resolving via the FK and returning `{ documentId, markdown, markers } | null`.
- [x] Call it in [handleExecuteMeeting.ts:94](apps/server/src/mastra/routeHandlers/event-execution/handleExecuteMeeting.ts) and thread `meetingNotes` into the workflow input schema.
- [x] Carry `meetingNotes` through `preExecutionSetupStep` at [on-event-agent-execution-workflow.ts:243](apps/server/src/mastra/workflows/event-execution/on-event-agent-execution-workflow.ts) into `orchestratorAgentStep` at [:302](apps/server/src/mastra/workflows/event-execution/on-event-agent-execution-workflow.ts).
- [x] Render the notes as a delimited `## Human notes from this meeting` block with the notes-beat-transcript instruction.
- [x] Add `source_marker_id` and `source_document_id` to `userTasks` in [apps/server/src/db/aop-schema.ts](apps/server/src/db/aop-schema.ts), with the migration.
- [x] Teach `run-post-event-executor` at [orchestrator-dispatch-tools.ts:433](apps/server/src/mastra/tools/event-execution/orchestrator-dispatch-tools.ts) to create tasks from `task` / `next-step` markers, stamping the source pair and skipping markers that already produced a task.
- [x] Pass `pain` / `objection` / `competitor` markers to `run-crm-updater` as extraction candidates, via a typed optional `noteSignals` input folded into the updater's instructions under `<rep_marked_signals>`. These are the highest-confidence input the agent gets: a human marked them deliberately rather than them being inferred from a transcript.
- [x] Add `appendTriageSection(documentId, summary)` writing through `applyUpdate` with `origin: 'agent'`, then setting `notes_triaged_at` and `notes_triage_run_id`.
- [x] Wire the `run-triage` action (phase `after`) to fire the same path for a meeting whose automatic run was skipped.

**Tests:**

- [x] `apps/server/src/services/meetings/__tests__/load-meeting-notes.test.ts` — null with no FK, parses markers, ignores resolved markers.
- [x] `apps/server/src/mastra/routeHandlers/event-execution/__tests__/meeting-notes-in-execution.test.ts` — notes reach the orchestrator payload; a null-notes meeting produces a byte-identical prompt to today's.
- [x] `apps/server/src/mastra/tools/event-execution/__tests__/triage-idempotency.test.ts` — running triage twice over the same markers creates each task exactly once.
- [x] `apps/server/src/services/meetings/__tests__/append-triage-section.test.ts` — the section appends without truncating human content and stamps both columns.
- [x] `timeout 300 pnpm --filter @zero/server exec vitest run src/services/meetings/__tests__/ src/mastra/tools/event-execution/__tests__/triage-idempotency.test.ts`

### Phase 10 — Retarget every entry point, then relocate the agent

**Goal:** Every surface that shows a meeting routes into the Meetings tab, and only then does the prep agent stop being listed elsewhere in the deal. Order matters: retarget first, relocate second.

- [x] Add `apps/mail/modules/conversations/utils/open-meeting.ts` exporting `openMeeting({ conversationId, eventId })`.
- [x] Retarget `handleOpenMeetingPrepFile` at [use-calendar-canvas-actions.ts:15](apps/mail/modules/agentCanvas/hooks/use-calendar-canvas-actions.ts) to `openMeeting`, keeping the old name as a thin alias.
- [x] Route `AgendaEventBlock` clicks at [AgendaEventBlock.tsx:46](apps/mail/modules/agentCanvas/components/AgendaEventBlock.tsx) through `openMeeting`, passing the event id.
- [x] Change `AgendaMeetings`' `openTarget` default at [AgendaMeetings.tsx:66](apps/mail/modules/agentCanvas/components/AgendaMeetings.tsx) from `'meeting-prep'` to `'meetings'` and drop the dead prep branch.
- [x] Retarget the context-menu item at [CalendarEventContextMenu.tsx:39](apps/mail/modules/calendar/components/CalendarEventContextMenu.tsx) to `openMeeting`, relabelled "Open meeting".
- [x] Add an "Open meeting" button to [EventDetailsPopover.tsx:1137](apps/mail/modules/calendar/components/EventDetailsPopover.tsx) when the event resolves to a conversation.
- [x] Retarget the meeting-prep hotkey at [conversation-display-hotkeys.tsx:47](apps/mail/modules/conversations/utils/conversation-display-hotkeys.tsx) and add `M` for the Meetings tab.
- [x] Add a `?conversationId={id}/meetings/{eventId}` deep-link arm so notifications can point at one meeting.
- [x] Retarget the `MEETING_PREP_OPEN_FILE` sentinel so it routes to the Meetings tab instead of opening a doc in Files — old notification deep-links must keep working.
- [x] **Only now:** add `SYSTEM_AGENT_NAMES.MEETING_PREP` to `HIDDEN_AGENT_NAMES` at [AgentRow.tsx:65](apps/mail/modules/conversations/components/AgentRow.tsx) so `sortAgents` drops it from the conversation's agent rows.
- [x] Add the `RELOCATED_AGENT_NAMES` set and the `listed` flag to `buildAgentDocs` at [resolveOpenDoc.ts:76](apps/mail/modules/conversations/components/files/resolveOpenDoc.ts) — built and resolvable, not listed. Do **not** add meeting-prep to the existing `HIDDEN_AGENT_NAMES` at [:43].
- [x] Filter the Files tab's Agents folder on `listed` in [FilesTab.tsx:36](apps/mail/modules/conversations/components/files/FilesTab.tsx).
- [x] Remove the now-redundant no-show button from the agenda event card.
- [x] Write `apps/mail/docs/wiki/meetings-tab.md` — the phase model, the prep-status state machine and its reason codes, the dedup-key contract, the archive-correlation ladder, the marker contract, the template precedence chain, the FK-vs-path addressing rule, the HIDDEN-vs-RELOCATED distinction, and the notes-beat-transcript decision.
- [x] Add a "Conversation-scoped agents and meeting notes" section to [apps/server/docs/wiki/agent-workspace.md](apps/server/docs/wiki/agent-workspace.md) covering the scope parameter, the archive metadata stamp (which closes G2 for archives), and what the post-event executor now receives.

**Tests:**

- [x] `apps/mail/tests/modules/conversations/open-meeting.test.ts` — ordering: `focalMeetingId` survives `openConversation`.
- [x] `apps/mail/tests/modules/conversations/relocated-agents.test.ts` — meeting-prep is absent from the agent rows and from the Files Agents folder, **and** `resolveOpenDoc('__meeting_prep__', …)` still resolves its doc.
- [x] `apps/mail/tests/modules/agentCanvas/agendaMeetings-open-target.test.tsx` — clicking a card opens the Meetings tab with the right `focalMeetingId`.
- [x] `apps/mail/tests/app/meeting-deep-link.test.ts` — both `?conversationId={id}/meetings/{eventId}` and the legacy `{id}/files/__meeting_prep__` land on the Meetings tab.
- [x] `timeout 300 pnpm --filter @zero/mail run types`
- [x] `timeout 300 pnpm --filter @zero/server run types`