strategic-overview-modules.md49.4 KBView on GitHub # Strategic Overview — seeded Strategist, per-conversation overview instances, conditional & rich fields, contact profiles
## 1) Introduction — goal, present state, future state
We want the Strategic Overview to be owned by a seeded, always-on **Strategist** system agent (like the CRM updater — worst case it does nothing), where each conversation carries its own **instantiated active list of overview items** (fields *and* embedded docs) materialized from an AOP-level catalog by evaluating per-item conditions against that deal's facts, and where personalization is a natural-language prompt the main chat agent applies to the Strategist's config. A single Strategist that manages the whole overview from its own playbook doc is the default and is fully sufficient; the ability to *reference and dispatch* further subagents (a battlecard, a stakeholder map) is an available option, not a requirement — nothing here forces recursion. Today the Strategist is not seeded for the general population (only `crm-updater, next-steps, meeting-prep, daily-agenda` are in `SYSTEM_SUBAGENT_FILENAMES` at [aop-agents.ts:176](apps/server/src/services/aop/aop-agents.ts); `SystemAgentName.STRATEGIST` exists in the enum but is jesse-only backfilled), the overview layout is one AOP-wide `displayConfig.strategicOverview` of scalar-only `topRow`+`sections` ([aop-schema.ts:447](apps/server/src/db/aop-schema.ts)) whose empty state falls back to *dumping every custom field* ([StrategicOverviewTab.tsx:47](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx)), with no conditional visibility and no rich rendering (only a `linkedDocPath` link icon), and per-contact research produced by Meeting-Prep/Multithreader is trapped in conversation-level markdown and never reaches the person (`crmContacts.notes`/`customFields` exist but are unsurfaced, and there is no `contact/{…}` document scope). We will (a) seed the Strategist as a real always-on agent — both its `subagents/strategist.md` doc **and** a `<ref>` in every account's playbook (new via the template, existing via backfill), since a subagent only runs when the playbook references it; (b) evolve the overview into a two-layer catalog→instance model with a small declarative condition grammar plus an agent-relevance gate; (c) add markdown field rendering and inline document-embed items (reusing the doc hydrator + `ConversationLayoutItem`'s existing `doc` kind); (d) add a `contact/{personKey}` document scope so durable research accumulates on the person and surface the existing structured notes; and (e) give the main chat agent tools to read and mutate the Strategist's config so an AE personalizes by prompt. The empty overview no longer dumps generic CRM fields — it renders the **seeded strategic fields empty**, with a persistent "Add & configure your overview" control and a "Populate overview" action that invokes the Strategist; and the Strategist's seeded instructions carry a durable "how to manage the conversation overview context" section so the capability is self-documenting and survives future edits. The AE-facing install *gallery/wizard* is out of scope (deferred). Two attribute fields ship early: `risk` is **reused** from the native `crmConversations.risk` column, and a new `on_track` select is seeded on every deal.
## 2) Present state
### 2.1 Architecture diagram
```text
SEED (per account):
seedPlaybookFiles → SYSTEM_SUBAGENT_FILENAMES = { crm-updater, next-steps,
meeting-prep, daily-agenda } ← NO strategist
computeStrategicSeedGapFill → Deals AOP.customFieldDefinitions += STRATEGIC_OVERVIEW_FIELDS
+ displayConfig.strategicOverview = STRATEGIC_OVERVIEW_LAYOUT (topRow+sections)
│
EVENT → on-event-orchestrator dispatches @subagents/<name> in fixed order
crm-updater → (strategy subagents if playbook lists them) → next-steps
│ each runAgent() writes owned fields (ownerAgentId) + an
│ agent-namespace overview doc (agentOverviewPath)
▼
RENDER (per conversation, but layout is AOP-wide):
StrategicOverviewTab
layout = aop.displayConfig.strategicOverview ?? buildFallbackLayout(defs)
topRow.map(renderStat) → scalar badges only
sections.map(renderTextSection) → scalar text / textarea only
linkedDocPath → a 🔗 icon that navigates to the doc (no inline embed)
│
CONTACTS (parallel, disconnected):
peopleGlobal (enrichment) ─ crmContacts{notes,customFields} (unsurfaced)
─ conversationContacts{role} ContactsTab renders enriched people
Meeting-Prep / Multithreader → conversation/{id}/agent-{agentId}/overview (markdown)
per-attendee research NEVER flows down to the contact record
```
### 2.2 Step-by-step walkthrough
1. **System subagents seeded** — `SYSTEM_SUBAGENT_FILENAMES` at [aop-agents.ts:176](apps/server/src/services/aop/aop-agents.ts) is `{ crm-updater, next-steps, meeting-prep, daily-agenda }`; the default playbook + docs are built by `buildPlaybookTemplate` / `seedPlaybookFiles` at [seed-playbook.ts:147](apps/server/src/services/playbook/seed-playbook.ts) with builders `buildCrmUpdaterContent`/`buildNextStepsContent`/`buildMeetingPrepContent`/`buildDailyAgendaContent` ([seed-playbook.ts:217-315](apps/server/src/services/playbook/seed-playbook.ts)). `SystemAgentName.STRATEGIST` exists ([aop-schema.ts:220](apps/server/src/db/aop-schema.ts)) but is **not** seeded — no builder, not in the filename set, not referenced in the default playbook.
- Seeded subagent doc frontmatter shape:
```yaml
name: crm-updater
description: Keeps objective CRM fields current
model: sonnet
enabled: true
agent_id: <uuid>
fill_instructions: Customize what fields this agent focuses on…
```
2. **Strategic fields + layout seeded** — `computeStrategicSeedGapFill` at [strategic-overview-fields.ts:195](apps/server/src/services/crm/strategic-overview-fields.ts) idempotently adds `STRATEGIC_OVERVIEW_FIELDS` ([:26](apps/server/src/services/crm/strategic-overview-fields.ts), 12 fields: forecast, the_play, how_we_win, risks, 8× meddpicc_*) and sets `displayConfig.strategicOverview = STRATEGIC_OVERVIEW_LAYOUT` ([:161](apps/server/src/services/crm/strategic-overview-fields.ts)) when absent, never clobbering user edits.
- `StrategicOverviewConfig` ([aop-schema.ts:447](apps/server/src/db/aop-schema.ts)) — **scalar only, AOP-wide**:
```ts
{ topRow: [{fieldId:'status',kind:'cedar'}, …5 cells],
sections: [{id:'the_play',title:'The play',fieldIds:['the_play']},
{id:'meddpicc',title:'MEDDPICC',fieldIds:['meddpicc_metrics', …8]}] }
```
3. **Native attribute columns already exist** — `crmConversations` at [crm-schema.ts:429](apps/server/src/db/crm-schema.ts) already has `status` ([:455](apps/server/src/db/crm-schema.ts)), `priority` ([:456](apps/server/src/db/crm-schema.ts)), and **`risk` ([:460](apps/server/src/db/crm-schema.ts))** ("enum low/medium/high, agent-maintained by the crm-updater and a signal in the conversation index"), `dealValue` ([:468](apps/server/src/db/crm-schema.ts)). There is **no** `on_track` and **no** per-conversation overview list column.
4. **Company facts available** — `companiesGlobal` at [crm-schema.ts:140](apps/server/src/db/crm-schema.ts) carries `linkedinHeadcount` ([:173](apps/server/src/db/crm-schema.ts)) and `employeeCountRange` ([:174](apps/server/src/db/crm-schema.ts)), joined to a conversation via `primaryCompanyId → crmCompanyRelationships`. These are the facts a segment/size condition can read.
5. **Event dispatch** — the on-event orchestrator ([on-event-orchestrator-agent.ts:142](apps/server/src/mastra/agents/on-event-orchestrator-agent.ts)) runs subagents in a fixed order (crm-updater → strategy subagents the playbook lists → next-steps); each subagent is resolved + dispatched by `runSubagentTool` ([runSubagentTool.ts](apps/server/src/mastra/tools/event-execution/runSubagentTool.ts)), which honors `frontmatter.enabled === false` (skip, [:159](apps/server/src/mastra/tools/event-execution/runSubagentTool.ts)) and builds a `syntheticAgent` with **no model field** ([:181-200](apps/server/src/mastra/tools/event-execution/runSubagentTool.ts)) — so `frontmatter.model` is display-only, not wired to runtime selection.
6. **Subagents write fields + a per-deal doc** — each `runAgent` gives the agent `update-conversation-fields` and an agent-namespace overview doc at `agentOverviewPath` ([convention-paths.ts:76](apps/server/src/services/documents/convention-paths.ts) → `conversation/{id}/agent-{agentId}/overview`); field writes are gated by `ownerAgentId` ([aop-schema.ts:316](apps/server/src/db/aop-schema.ts), Phase 10 of [[strategic-overview]]).
7. **Overview render is AOP-wide + scalar, with a bad empty state** — `StrategicOverviewTab` resolves `const layout = normalizeLegacyLayout(aop?.displayConfig?.strategicOverview ?? buildFallbackLayout(defs))` ([StrategicOverviewTab.tsx:424](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx)); when no strategic layout is configured, `buildFallbackLayout` ([:47](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx)) synthesizes a section that **dumps every non-internal custom field** — so an unconfigured deal shows a wall of generic CRM fields instead of the strategic set. It then does `topRow.map(renderStat)` ([:677](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx)) and `sections.map` special-cased by id ([:706](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx)). `renderStat` ([:441](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx)) and `renderTextSection`/`FieldValue` ([:617](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx),[:221](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx)) emit only badges/text/textarea. The only doc affordance is a 🔗 icon from `linkedDocPath` calling `openDoc` ([:648](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx)). No conditional visibility exists anywhere (empty cells simply drop). There is no "configure" or "populate" affordance in the tab body.
- Note the *doc-capable* item type already exists but is used only by the CRM card / overview-tab configs, not the strategic overview: `ConversationLayoutItem` ([aop-schema.ts:387](apps/server/src/db/aop-schema.ts)) has `fieldId:'doc'` + `docPath`.
8. **Contacts model** — three layers: `peopleGlobal` (enrichment, keyed by email), `crmContacts` ([crm-schema.ts:284](apps/server/src/db/crm-schema.ts)) with per-user `notes` ([:309](apps/server/src/db/crm-schema.ts)) + `customFields` ([:312](apps/server/src/db/crm-schema.ts)) (both **not** in the frontend `PersonGlobal` type, so unsurfaced), and `conversationContacts` (join with `role`). Rendered by `ContactsTab.tsx` / `DirectoryTab.tsx`. Doc scopes today are org/user/conversation/thread only ([convention-paths.ts](apps/server/src/services/documents/convention-paths.ts)); there is **no** `contact/{…}` scope, so per-attendee research from Meeting-Prep ("Who you're meeting") and the Multithreader decision-maker map stays as conversation-level markdown and never lands on the person.
## 3) Designed state
### 3.1 Architecture diagram
```text
SEED (per account, new + backfill):
SYSTEM_SUBAGENT_FILENAMES += 'strategist'; buildStrategistContent() seeds
subagents/strategist.md + a default playbook <ref> in <trigger type="any">
Strategic fields seeded with ownerAgentId = Strategist; + on_track (new select);
risk = REUSE native crmConversations.risk (surfaced, not re-created)
displayConfig.strategicOverview evolves → CATALOG (items + visibleWhen conditions)
│
┌─ CATALOG (AOP-level: what CAN appear) ───────────────────────────────────────┐
│ OverviewCatalogItem[] { key, kind:field|doc|contacts, fieldId?/docPath?, │
│ render:scalar|markdown, ownerAgentId?, visibleWhen?, section?, order } │
└───────────────────────────────┬──────────────────────────────────────────────┘
instantiateOverview(convId): resolveDealFacts + evaluate visibleWhen
▼
┌─ INSTANCE (crmConversations.overviewItems jsonb: what DOES appear here) ──────┐
│ OverviewInstanceItem[] { key, kind, fieldId?/docPath?, render, order, │
│ addedBy:seed|agent|user, hidden? } ← Strategist & user & chat-agent edit │
└───────────────────────────────┬──────────────────────────────────────────────┘
▼
EVENT → Strategist (always-on orchestrator, pipelineOrder 2):
1. re-instantiate overviewItems for this deal (conditions may have changed)
2. dispatch enabled module subagents whose runWhen holds (run-subagent)
3. write assessment doc + distill owned fields (relevance gate: mark an
irrelevant item hidden:true; unpopulated-but-relevant renders blank)
▼
RENDER: StrategicOverviewTab reads conversation.overviewItems (fallback: SEEDED
strategic catalog EMPTY — never a generic-CRM-field dump)
field(scalar) → badge field(markdown) → <Markdown> doc → <DocEmbed docPath>
contacts → ContactsDirectorySection (roster → links to contact profile doc)
footer → [Add & configure your overview] (→ config) [Populate overview] (→ chat
message that invokes the Strategist for this deal)
│
CONTACTS: contact/{personKey}/profile doc scope (durable research accumulates);
crmContacts.notes/customFields surfaced in ContactCard; Meeting-Prep/Multithreader
write per-attendee profiles down into the person doc
│
PERSONALIZE (prompt, not gallery): main chat agent
configure-strategist tool → mutate catalog items / conditions / owned fields
manage-subagent tool → create/enable/disable module subagents
```
### 3.2 Step-by-step walkthrough
1. **Seed the Strategist as an always-on system agent** — add `'strategist'` to `SYSTEM_SUBAGENT_FILENAMES` ([aop-agents.ts:176](apps/server/src/services/aop/aop-agents.ts)), a `buildStrategistContent()` builder + entry in the system-subagent array ([seed-playbook.ts:591](apps/server/src/services/playbook/seed-playbook.ts)), and a `<ref>` to it inside the default `<trigger type="any">` in `buildPlaybookTemplate` ([seed-playbook.ts:147](apps/server/src/services/playbook/seed-playbook.ts)). `triggerTypeFromFilename` ([aop-agents.ts:183](apps/server/src/services/aop/aop-agents.ts)) already defaults to `EVENT_OCCURRED`. Backfill via a script mirroring the existing `seed-strategist-agents.ts`. Its instructions gate it ("if there is no new signal, make no changes") so worst-case it is a no-op like the CRM updater.
2. **Seed the two attribute fields** — in `STRATEGIC_OVERVIEW_FIELDS` ([strategic-overview-fields.ts:26](apps/server/src/services/crm/strategic-overview-fields.ts)) add `on_track` (select) and register `risk` as a **cedar** cell (it maps to the native `crmConversations.risk` column, not a custom field). Add both to the top row of the evolved catalog.
- `on_track` field def:
```json
{ "id":"on_track","type":"select","label":"On track","displayOrder":0,
"options":[{"value":"on_track","label":"On track","color":"#22c55e","enumOrder":0},
{"value":"fast","label":"Fast","color":"#3b82f6","enumOrder":1},
{"value":"stalling","label":"Stalling","color":"#f59e0b","enumOrder":2},
{"value":"cold","label":"Cold","color":"#ef4444","enumOrder":3}],
"signal":{"enabled":true,"description":"green = On track/Fast; yellow = Stalling; red = Cold."},
"ownerAgentId":"<strategist id>" }
```
3. **Evolve the AOP layout into a catalog** — replace the scalar `StrategicOverviewConfig` with an item catalog carrying conditions + render hints + doc/contacts kinds (see 3.3). A `normalizeStrategicCatalog(config)` migrates a legacy `{topRow, sections}` into `OverviewCatalogItem[]` (each `topRow` cell → a `field` item order 0-4; each `section` field → a `field` item under that section) so existing AOPs keep working with zero data migration.
- Catalog item examples:
```json
[ {"key":"on_track","kind":"field","fieldId":"on_track","fieldType":"custom","render":"scalar","displayOrder":0},
{"key":"risk","kind":"field","fieldId":"risk","fieldType":"cedar","render":"scalar","displayOrder":1},
{"key":"the_play","kind":"field","fieldId":"the_play","fieldType":"custom","render":"markdown","section":"the_play","ownerAgentId":"<strat>"},
{"key":"battlecard","kind":"doc","docPath":"conversation/{id}/agent-<battlecard>/overview","title":"Battlecard","visibleWhen":{"all":[{"fact":"headcount","op":"gte","value":200}]}},
{"key":"stakeholders","kind":"contacts","title":"Stakeholders","visibleWhen":{"fact":"headcount","op":"gte","value":75}} ]
```
4. **Resolve deal facts** — new `resolveDealFacts(db, conversationId)` in [deal-facts.ts](apps/server/src/services/crm/deal-facts.ts) returns a flat record from the conversation + joined company: `{ status, risk, dealValue, headcount, onTrack, segment, field:<key> }`. `headcount` from `companiesGlobal.linkedinHeadcount`/`employeeCountRange` ([crm-schema.ts:173](apps/server/src/db/crm-schema.ts)); `segment` derived from headcount bands (SMB/mid-market/enterprise) until an explicit column exists.
- Facts snapshot:
```json
{ "status":"negotiation","risk":"high","dealValue":120000,"headcount":420,
"onTrack":"stalling","segment":"enterprise","field:meddpicc_champion":"Priya" }
```
5. **Evaluate conditions (pure)** — new `evaluateVisibleWhen(cond, facts): boolean` in [overview-conditions.ts](apps/server/src/services/crm/overview-conditions.ts): a single `{fact,op,value}` or an `all`/`any` group; ops `eq|neq|gt|gte|lt|lte|in|exists`. Deterministic and unit-testable; missing fact ⇒ `exists`=false, comparisons=false.
6. **Instantiate the per-conversation list** — new `instantiateOverview(db, conversationId)` in [overview-instance.ts](apps/server/src/services/crm/overview-instance.ts): read catalog + `resolveDealFacts`, keep items whose `visibleWhen` holds (or is absent), map to `OverviewInstanceItem` with `addedBy:'seed'`, **merge** onto any existing `crmConversations.overviewItems` (preserving `addedBy:'agent'|'user'` items and `hidden` overrides, keyed by `key`), and persist. Called on conversation create and at the top of every Strategist run.
- Persisted instance:
```json
[ {"key":"on_track","kind":"field","fieldId":"on_track","order":0,"addedBy":"seed"},
{"key":"risk","kind":"field","fieldId":"risk","order":1,"addedBy":"seed"},
{"key":"battlecard","kind":"doc","docPath":"conversation/…/overview","addedBy":"agent"} ]
```
7. **Strategist manages the overview (dispatch is optional)** — `STRATEGIST_INSTRUCTIONS` (seeded doc body) directs it to: re-instantiate the overview list, write its assessment doc at `agentOverviewPath`, and distill concise conclusions into owned fields — applying a **relevance gate**: an owned field that is genuinely not relevant to this deal is marked `hidden:true` on its instance item (an explicit suppression), while a relevant-but-not-yet-populated field simply renders blank. This keeps the empty-but-expected fields visible (so the user knows what the Strategist will fill) and only removes what the agent deliberately deemed irrelevant (the agent-decided half of 3a; the declarative `visibleWhen` is the hard half). A single Strategist doing all of this from its own playbook is the default; *optionally*, if the playbook lists module subagents, it may dispatch the ones whose `runWhen` holds via `run-subagent`. The seeded body includes a durable **"How to manage the conversation overview context"** section (how items, conditions, docs, and the instance list work) so the capability is self-documenting and a later edit or reseed does not silently strip it.
8. **Render from the instance, with a strategic (not generic) empty state** — `StrategicOverviewTab` ([StrategicOverviewTab.tsx:424](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx)) reads `conversationData.overviewItems`, sorts by `order`, and dispatches by `kind`: `field` + `render:'scalar'` → existing `renderStat`/`FieldValue`; `field` + `render:'markdown'` → a new `<MarkdownField>`; `doc` → a new `<DocEmbedCell docPath>`; `contacts` → a new `<ContactsDirectorySection>`. `buildFallbackLayout` ([:47](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx)) is **removed**: when a conversation has no instance yet, the fallback is the **seeded strategic catalog rendered with empty values** (the same strategic fields, blank), never a dump of generic custom fields. A persistent footer renders two controls regardless of fill state: **"Add & configure your overview"** (navigates to the config editor) and **"Populate overview"** (sends a prefilled chat message — "Populate the strategic overview for this deal" — that the main chat agent handles by invoking the Strategist for this conversation via the existing `spawn-subagent` path). Because the empty cells are always present, the footer is always reachable by scrolling.
9. **Markdown field + doc embed** — `<MarkdownField>` renders a field's string value through the existing markdown renderer used by the document viewer (read-only, `prose`-styled). `<DocEmbedCell>` fetches the doc via the existing `documents.getDoc` query and renders its markdown inline, with a header + a ⧉ open-in-viewer affordance (reusing the `OpenArtifactPanel`/hydrator machinery from [[chat-context-set]]). Both are collapsible sections in the overview.
10. **Contact profile doc scope** — add `contactProfilePath(personKey)` = `contact/{personKey}/profile` + `isContactDocPath` to [convention-paths.ts](apps/server/src/services/documents/convention-paths.ts) and a born-as-contact write-hook branch in [documents/index.ts](apps/server/src/services/documents/index.ts) (mirroring `isSubagentDocPath` at [convention-paths.ts:231](apps/server/src/services/documents/convention-paths.ts)). `personKey` = `crmContacts.personId` when present, else an email slug. Durable *research* is org-scoped (shared); personal *notes* stay per-user on `crmContacts.notes`.
11. **Surface structured contact notes** — add `notes` + `customFields` to the frontend `PersonGlobal` type ([apps/mail/modules/crm/types/index.ts](apps/mail/modules/crm/types/index.ts)) and to the `crm.getConversation` payload; render an editable Notes field + custom attributes in `ContactCard` ([ContactsTab.tsx](apps/mail/modules/conversations/components/contacts/ContactsTab.tsx)), each card linking to its `contact/{personKey}/profile` doc.
12. **Research flows down to the person** — amend the Meeting-Prep ([seed-meeting-prep-agent.ts](apps/server/src/db/migrations/scripts/seed-meeting-prep-agent.ts)) and Multithreader instructions to also upsert a per-attendee section into `contact/{personKey}/profile`, so durable background accumulates once per person and is reused across every deal they appear in.
13. **Prompt-driven personalization** — new chat-agent tools registered in both tool maps of [chat-agent.ts:524](apps/server/src/mastra/agents/chat-agent.ts): `configure-strategist` (read/patch the AOP catalog — add/remove/reorder items, set `render`, set `visibleWhen`, set a field's `ownerAgentId`) wrapping `aop.updateAop`, and `manage-subagent` (create/enable/disable a module subagent) wrapping `aop.createSubagent` ([aop.ts:286](apps/server/src/trpc/routes/aop.ts)) + `aop.updateSubagentHeader` ([aop.ts:424](apps/server/src/trpc/routes/aop.ts)). An AE personalizes by prompting the main agent ("for enterprise deals always show a battlecard doc") which the agent translates into catalog + subagent mutations. The install *gallery/wizard* is deferred.
### 3.3 Schema
Full schema:
```ts
// apps/server/src/db/aop-schema.ts — condition grammar (NEW)
export type OverviewFact =
| 'status' | 'risk' | 'onTrack' | 'dealValue' | 'headcount' | 'segment'
| `field:${string}`; // any custom field key
export type OverviewConditionOp =
| 'eq' | 'neq' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'exists';
export type OverviewCondition = {
fact: OverviewFact;
op: OverviewConditionOp;
value?: string | number | Array<string | number>; // omitted for 'exists'
};
export type VisibleWhen =
| OverviewCondition
| { all: OverviewCondition[] }
| { any: OverviewCondition[] };
// apps/server/src/db/aop-schema.ts — AOP-level CATALOG item (NEW; supersedes StrategicOverviewCell/Section)
export type OverviewItemKind = 'field' | 'doc' | 'contacts';
export type OverviewCatalogItem = {
key=[redacted]; // stable id, unique within the catalog
kind: OverviewItemKind;
fieldId?: string; // kind:'field' — cedar field or custom field key
fieldType?: 'cedar' | 'custom'; // kind:'field'
docPath?: string; // kind:'doc' — supports {id} placeholder → conversationId
title?: string; // display label / section title
section?: string; // grouping id (e.g. 'the_play','meddpicc'); absent = top row
render?: 'scalar' | 'markdown'; // NEW — field render mode; default 'scalar'
displayOrder: number;
ownerAgentId?: string; // module that contributes/maintains this item
visibleWhen?: VisibleWhen; // NEW — declarative gate; absent = always
defaultValue?: string;
};
// Evolved config: catalog replaces topRow/sections; both kept optional for one release.
export type StrategicOverviewConfig = {
items?: OverviewCatalogItem[]; // NEW — the catalog
topRow?: StrategicOverviewCell[]; // legacy, normalized into items on read
sections?: StrategicOverviewSection[]; // legacy, normalized into items on read
};
// apps/server/src/db/aop-schema.ts — per-conversation INSTANCE item (NEW)
export type OverviewInstanceItem = {
key=[redacted]; // catalog key, or ad-hoc for agent/user adds
kind: OverviewItemKind;
fieldId?: string;
fieldType?: 'cedar' | 'custom';
docPath?: string; // resolved (no placeholder)
title?: string;
section?: string;
render?: 'scalar' | 'markdown';
displayOrder: number;
addedBy: 'seed' | 'agent' | 'user'; // NEW — provenance; seed items re-materialize, others persist
hidden?: boolean; // NEW — suppressed by agent/user without deleting
};
```
```sql
-- apps/server/src/db/crm-schema.ts — crmConversations gains the instance list (NEW column)
ALTER TABLE crm_conversations
ADD COLUMN overview_items jsonb; -- OverviewInstanceItem[]; NULL ⇒ instantiate lazily
-- risk already exists: crm_conversations.risk text (low|medium|high) — REUSED, not added
-- on_track is a custom field in customFieldDefinitions, NOT a native column
-- apps/server/src/services/crm/strategic-overview-fields.ts — on_track custom field (NEW seed)
-- customFieldDefinitions['on_track'] = { type:'select', options:[On track|Fast|Stalling|Cold], signal }
```
```ts
// apps/server/src/services/documents/convention-paths.ts — contact doc scope (NEW)
export function contactProfilePath(personKey=[redacted] string; // `contact/${personKey}/profile`
export function isContactDocPath(path: string): boolean; // /^contact\/[^/]+\/[^/]+$/
// apps/server/src/services/crm/deal-facts.ts (NEW)
export type DealFacts = Record<OverviewFact, string | number | undefined>;
export async function resolveDealFacts(db: Db, conversationId: string): Promise<DealFacts>;
// apps/server/src/services/crm/overview-conditions.ts (NEW, pure)
export function evaluateVisibleWhen(cond: VisibleWhen | undefined, facts: DealFacts): boolean;
// apps/server/src/services/crm/overview-instance.ts (NEW)
export function normalizeStrategicCatalog(config: StrategicOverviewConfig): OverviewCatalogItem[];
export async function instantiateOverview(db: Db, conversationId: string): Promise<OverviewInstanceItem[]>;
// apps/mail/modules/crm/types/index.ts — PersonGlobal gains (NEW, mirrors crmContacts)
// notes?: string;
// customFields?: Record<string, unknown>;
```
Relationship diagram:
```text
┌─────────────────────────────────────────────┐ ┌──────────────────────────────┐
│ agentOperatingProcedures │ │ aopAgents / subagent docs │
│ id (PK) │ │ id (PK) │
│ displayConfig.strategicOverview ▼ │ │ name (Strategist, module…) │
│ { items: OverviewCatalogItem[] } │ │ frontmatter{model,enabled, │
│ key,kind,fieldId?/docPath?, │ │ runWhen?, fill_instructions}│
│ render,visibleWhen?,ownerAgentId ───FK──┼───────►│ │
│ customFieldDefinitions ▼ │ └───────┬──────────────────────┘
│ Record<key, CustomFieldDefinition> │ │ agentOverviewPath(conv,agent)
│ { …, ownerAgentId?, signal? } │ ▼
└──────────────┬───────────────────────────────┘ documents:
normalizeStrategicCatalog + resolveDealFacts · conversation/{id}/agent-{agent}/overview
+ evaluateVisibleWhen = instantiateOverview · contact/{personKey}/profile ◄── NEW scope
▼ · organisation/... user/... thread/...
┌─────────────────────────────────────────────┐
│ crmConversations │ ┌──────────────────────────────┐
│ id (PK) │──FK────►│ crmCompanyRelationships │
│ aopId ──FK► agentOperatingProcedures │ primary │ → companiesGlobal │
│ status / risk(low|med|high) / dealValue │ Company │ linkedinHeadcount, │
│ overview_items ▼ ◄── NEW column │ │ employeeCountRange (facts) │
│ OverviewInstanceItem[] │ └──────────────────────────────┘
│ { key,kind,render,addedBy,hidden? } │
└──────────────┬───────────────────────────────┘
│ conversationContacts (role)
▼
┌─────────────────────────────────────────────┐ ┌──────────────────────────────┐
│ crmContacts (per-user) │──ref──►│ peopleGlobal (enrichment) │
│ personEmail / personId (personKey) │ email │ email (PK), role, summary… │
│ notes ◄── surfaced customFields ◄─surfaced│ └──────────────────────────────┘
│ personKey ──► contact/{personKey}/profile doc (durable research accumulates) │
└─────────────────────────────────────────────┘
```
## 4) Implementation phases
### Phase 1 — Attribute fields: reuse `risk`, seed `on_track`
**Goal:** Every deal shows an `on_track` select and the native `risk` in the overview, seeded idempotently, signal-enabled.
- [x] Add the `on_track` select field def (options On track/Fast/Stalling/Cold + signal) to `STRATEGIC_OVERVIEW_FIELDS` ([strategic-overview-fields.ts:26](apps/server/src/services/crm/strategic-overview-fields.ts)).
- [x] Add `on_track` (custom) and `risk` (cedar) cells to the top row of `STRATEGIC_OVERVIEW_LAYOUT` ([strategic-overview-fields.ts:161](apps/server/src/services/crm/strategic-overview-fields.ts)). **Decision:** widened the default top row to **6 cells** (`nextStepDate, on_track, status, risk, forecast, dealValue`) and dropped `lastContactedAt` — non-lossy for the strategic fields; the catalog rework (Phase 4/5) replaces this layout anyway. Only affects fresh seeds (gap-fill never rewrites an existing layout).
- [x] Render the native `risk` cedar cell in `renderStat` ([StrategicOverviewTab.tsx:459](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx)) as a signal-colored select (low 🟢 / medium 🟡 / high 🔴), reading `conversation.risk` (new `RISK_OPTIONS` const). Frontend — manual visual check.
- [x] Confirm `computeStrategicSeedGapFill` ([strategic-overview-fields.ts:213](apps/server/src/services/crm/strategic-overview-fields.ts)) gap-fills `on_track` into existing AOPs without clobbering (additive; existing layouts untouched).
**Tests:**
- [x] Unit: `computeStrategicSeedGapFill` adds `on_track` when missing, no-ops on re-run, preserves user edits; `on_track` is a 4-option signal select; top row is 6 cells with `on_track`+`risk` — [seed-strategic.test.ts](apps/server/src/services/crm/__tests__/seed-strategic.test.ts) (8 passed).
- [x] `pnpm exec vitest run src/services/crm/__tests__/seed-strategic.test.ts` → 8 passed.
### Phase 2 — Strategist seeded as an always-on system agent
**Goal:** Every account (new + backfill) has a seeded, dispatchable Strategist that owns the strategic fields.
- [x] Add `'strategist'` to `SYSTEM_SUBAGENT_FILENAMES` ([aop-agents.ts:210](apps/server/src/services/aop/aop-agents.ts)).
- [x] Add `buildStrategistContent()` + a system-subagent array entry ([seed-playbook.ts](apps/server/src/services/playbook/seed-playbook.ts)) with `STRATEGIST_INSTRUCTIONS` ([aop-agents.ts:172](apps/server/src/services/aop/aop-agents.ts)) — assess deep → distill owned fields → relevance gate (hidden vs blank) → optional dispatch → optional recommendations.
- [x] Include a durable **"How to manage the conversation overview context"** section in `STRATEGIST_INSTRUCTIONS` so the capability is self-documenting and survives reseed/edits.
- [x] Add a `<ref>` to `@subagents/strategist` inside the default `<trigger type="any">` in `buildPlaybookTemplate` ([seed-playbook.ts](apps/server/src/services/playbook/seed-playbook.ts)) — placed between crm-updater and next-steps (the orchestrator's fixed order). `buildPlaybookTemplate` exported for unit testing.
- [x] Stamp `ownerAgentId = <strategist id>` via `assignStrategistFieldOwnership` ([strategist-ownership.ts](apps/server/src/services/aop/strategist-ownership.ts)), wired best-effort at the end of `seedPlaybookFiles`. **Divergence:** ownership covers the custom strategic fields **except `forecast`** (rep-owned) and **excludes native `risk`** (a column, not a custom field). `on_track` is included.
- [x] Backfill script `seed-strategist-agents.ts` ([migrations/scripts](apps/server/src/db/migrations/scripts/seed-strategist-agents.ts)) — ownership half, idempotent, jesse-scoped by default (`BACKFILL_ALL=1`). **Divergence:** it does **not** rewrite an already-authored `PLAYBOOK.md`; existing accounts lacking the strategist doc/`<ref>` are reported and left for a separate playbook-ref rollout (new accounts get it from the template; jesse already has it).
**Tests:**
- [x] Live headless drivers (as jesse): `strategist-dispatch-smoke.ts` (regression — `@subagents/strategist` compiled into the playbook, resolves to the row) and new `strategist-ownership-smoke.ts` — Strategist resolves + **11/11 present strategic fields owned** (on_track absent from jesse's live defs until next field-seed), dry-run stamp = 0. Read-only.
- [x] Unit: `strategist-seed.test.ts` (7 passed) — filename set, durable section, ref ordering, `stampStrategistOwnership` (stamps unowned, skips forecast, idempotent). `pnpm exec vitest run src/services/playbook/__tests__/strategist-seed.test.ts`.
- [x] `pnpm deps:check` clean (1216 modules, no circular imports); touched files typecheck clean.
### Phase 3 — Overview condition grammar + deal-facts resolver (pure)
**Goal:** A deterministic, unit-tested condition layer and fact resolver, wired to nothing yet.
- [x] Add `OverviewFact`/`OverviewConditionOp`/`OverviewCondition`/`VisibleWhen` types to [aop-schema.ts](apps/server/src/db/aop-schema.ts).
- [x] Add pure `evaluateVisibleWhen(cond, facts)` + `DealFacts` type + `getFact` in [overview-conditions.ts](apps/server/src/services/crm/overview-conditions.ts) (no DB — deterministically testable). **Note:** `DealFacts` lives in the pure module so the condition evaluator has no DB dependency; `deal-facts.ts` imports the type.
- [x] Add `resolveDealFacts(db, conversationId)` + pure `mapDealFacts`/`segmentFromHeadcount`/`parseHeadcountRange` in [deal-facts.ts](apps/server/src/services/crm/deal-facts.ts) — native columns off `crm_conversations`, headcount via `primaryCompanyId → crm_company_relationships → companies_global`, custom values via `getConversationFieldValues`. Segment banded smb (<100) / mid-market (<1000) / enterprise.
**Tests:**
- [x] Unit: `evaluateVisibleWhen` — each op, `all`/`any`, `field:<key>`, missing-fact falsy; `mapDealFacts`/segment banding/range parsing — [overview-conditions.test.ts](apps/server/src/services/crm/__tests__/overview-conditions.test.ts) (8 passed).
- [x] Live: `deal-facts-smoke.ts` (as jesse, read-only) — resolved real facts for "Peter @ Pirros" (headcount 43 → segment smb, custom values mapped); `evaluateVisibleWhen` ran (headcount>=75 → false, correct).
- [x] `pnpm exec vitest run src/services/crm/__tests__/overview-conditions.test.ts` → 8 passed; touched files typecheck clean.
### Phase 4 — Catalog model + per-conversation instantiation
**Goal:** The AOP layout becomes an item catalog; each conversation gets a materialized `overview_items` list.
- [x] Add `OverviewItemKind`/`OverviewCatalogItem` + `OverviewInstanceItem` types and evolve `StrategicOverviewConfig` to `items?` (topRow/sections now optional, normalized on read) ([aop-schema.ts](apps/server/src/db/aop-schema.ts)).
- [x] Add `overview_items jsonb` column to `crm_conversations` ([crm-schema.ts:490](apps/server/src/db/crm-schema.ts)) + migration `0051_conversation_overview_items.sql` + journal entry; **applied to the live DB** (idempotent `ADD COLUMN IF NOT EXISTS`, verified present as jsonb).
- [x] Add `normalizeStrategicCatalog(config)` (legacy topRow/sections → items) + pure `computeOverviewInstance(catalog, facts, existing, convId)` + `instantiateOverview(db, convId)` (materialize + persist) + `getOverviewItems` (lazy read) in [overview-instance.ts](apps/server/src/services/crm/overview-instance.ts).
- [x] Expose via a dedicated `crm.getOverviewItems({ conversationId })` query ([crm.ts](apps/server/src/trpc/routes/crm.ts)) with an access guard. **Divergence:** a dedicated query rather than bloating the strict `getConversation` output schema + hot path (the render reads it the same way). Lazy-instantiates on first read.
**Tests:**
- [x] Unit: `normalizeStrategicCatalog` maps a legacy layout; `computeOverviewInstance` keeps condition-passing seed items, preserves `addedBy:'agent'|'user'`, carries a prior `hidden` override, resolves the `{id}` doc placeholder, drops condition-failing items — [overview-instance.test.ts](apps/server/src/services/crm/__tests__/overview-instance.test.ts) (6 passed).
- [x] Live: `overview-instance-smoke.ts` (jesse) — materialized **16 items** for "Peter @ Pirros" from the normalized catalog, restored `overview_items` afterward (no residue).
- [x] Live tRPC e2e: `overview-items-trpc.e2e.test.ts` (gated `E2E_OVERVIEW=1`, `createCaller` as jesse) — route returns items + rejects an unknown conversation (access guard), restored. (createCaller runs under vitest, not raw tsx — css-sanitizer ESM quirk.)
- [x] `pnpm exec vitest run src/services/crm/__tests__/overview-instance.test.ts` (6) + `pnpm deps:check` clean (1221 modules); touched files typecheck clean (crm.ts pre-existing errors unrelated).
### Phase 5 — Render from the instance + markdown fields + doc embeds + strategic empty state
**Goal:** The overview renders the per-conversation instance (markdown fields + inline doc embeds), shows the seeded strategic fields empty when unfilled (never a generic-CRM dump), and always exposes configure/populate controls.
- [x] **Empty state fixed** — `buildFallbackLayout` ([StrategicOverviewTab.tsx](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.tsx)) no longer dumps every custom field; the no-layout fallback is the **strategic fields** (top row `nextStepDate/on_track/status/risk/forecast/dealValue` + the_play/how_we_win/risks/MEDDPICC sections), rendered blank. `isInternalFieldId` (now unused) deleted. **Refinement (per feedback):** every strategic field renders **even when the AOP has no def for it** — `STRATEGIC_FIELD_FALLBACK_DEFS` is merged *under* the AOP's own defs (real defs win), giving each field a label + type so the render (which drops def-less fields) shows it blank instead of omitting it.
- [x] Fetch the per-conversation instance via `crm.getOverviewItems` (new `useTRPC`+`useQuery`), and render `kind:'doc'` items as inline embeds — new [OverviewDocEmbed.tsx](apps/mail/modules/conversations/components/strategicOverview/OverviewDocEmbed.tsx) (fetch `documents.getDoc` by path → `ReadOnlyMarkdownView`, collapsible, ⧉ open-in-viewer, graceful degrade to an open link).
- [x] Persistent footer with **"Add & configure your overview"** (→ `editOverview` = `/agents/playbook?aop=<id>`) and **"Populate overview"** (prefills a chat prompt + `sendMessage` on a fresh thread — the same store pattern `AgentRow` uses to run an agent).
- [x] **Divergence — native `risk` is now read-only** in the top row (the conversation-update mutation's input type doesn't accept `risk`); it shows a signal-coloured label + dot. Corrects a Phase-1 type error (I'd only typechecked the server then).
- [ ] **Deferred (needs visual verification):** full replacement of the layout-based render with the item-model render, and a per-field `render:'markdown'` `<MarkdownField>`. The current render keeps the working scalar path + additively renders doc embeds; the empty-state + footer + doc-embed asks are met. Markdown rendering ships via `OverviewDocEmbed`/`ReadOnlyMarkdownView`.
**Tests:**
- [x] Frontend component test ([StrategicOverviewTab.test.tsx](apps/mail/modules/conversations/components/strategicOverview/StrategicOverviewTab.test.tsx)) — top-row labels, green SignalDot, and the always-present footer controls. **Also repaired a pre-existing red test** (it never mocked `useAgentsForConversation` → `aopAgents.listForAop`, broken since Phase 13); added deep-proxy tRPC + react-query mocks. 3 passed.
- [x] `pnpm exec jest modules/conversations/components/strategicOverview/StrategicOverviewTab` → 3 passed; touched non-test files typecheck clean.
- [ ] **Visual verification by the user** — open a deal's Strategic Overview: empty state shows strategic fields blank (not generic CRM), footer controls present, a doc-embed renders when the instance has a `doc` item.
### Phase 6 — Contact profile doc scope + surface structured notes
**Goal:** Individual contacts get a durable profile doc and their existing notes/customFields become visible.
- [x] Add `contactProfilePath(personKey)` + `isContactDocPath` + `contactKeyFromEmail` to [convention-paths.ts](apps/server/src/services/documents/convention-paths.ts). **Divergence:** no born-as-contact write-hook / new document type — a contact profile is a plain markdown `document`; the **path is the scope**. A doc type would only earn its keep with structured rendering (Phase 20's agent doc), which contacts don't need. This is the load-bearing enabler for Phase 7.
- [ ] **Deferred (manual-test + hot-path):** `notes`/`customFields` on `PersonGlobal` + the `crm.getConversation` contact payload. The service returns `people` strictly typed `peopleGlobal.$inferSelect[]` and validated by `HydratedConversationSchema`; joining `crmContacts.notes` cascades through the output schema + every consumer — a hot-path change with UI-only verification. Split out so the doc-scope enabler ships verified.
- [ ] **Deferred (manual-test):** editable Notes + link-to-profile in `ContactCard` ([ContactsTab.tsx](apps/mail/modules/conversations/components/contacts/ContactsTab.tsx)) and a `contacts`-kind `ContactsDirectorySection`. Depend on the payload above.
**Tests:**
- [x] Unit: `contactProfilePath`/`isContactDocPath`/`contactKeyFromEmail` — [contact-paths.test.ts](apps/server/src/services/documents/__tests__/contact-paths.test.ts) (3 passed).
- [x] Live: `contact-doc-smoke.ts` (as jesse) — write → read (content round-trips) → delete a `contact/{key}/profile` doc, no residue. PASS.
- [x] `pnpm exec vitest run src/services/documents/__tests__/contact-paths.test.ts` → 3 passed.
### Phase 7 — Research flows down to the contact
**Goal:** Meeting-Prep and Multithreader accumulate per-attendee research on the person, not just the conversation.
- [x] Amend `MEETING_PREP_INSTRUCTIONS` ([aop-agents.ts:19](apps/server/src/services/aop/aop-agents.ts)) with **Step 1b** — persist each attendee's durable background to `contact/{personKey}/profile` (via write-document), keyed by email slug when no stable id. **Note:** the instruction body lives in `MEETING_PREP_INSTRUCTIONS` (seeded into `meeting-prep.md`), not the older `seed-meeting-prep-agent.ts` migration.
- [x] Amend the Multithreader template instructions ([AgentsEditor.tsx:606](apps/mail/modules/aop/components/AgentsEditor.tsx)) to write each stakeholder's durable background into the same per-contact profile.
- [x] Idempotent re-write: instructions require READ → rewrite the `## Research` section **in place** (never a blind append), preserving `## Notes`/other sections; deal-specific stance stays in the deal, not the person profile.
**Tests:**
- [x] Unit: `research-flowdown.test.ts` — the seeded Meeting-Prep instructions carry the `contact/{personKey}/profile` directive + the idempotent-in-place rule (2 passed). Proves the directive shipped.
- [ ] **Deferred (sandbox LLM block):** live dispatch of Meeting-Prep to observe the agent actually writing the contact doc — the LLM/skill run can't be imported under tsx (css-sanitizer ESM quirk), same limitation as the chat e2e. The doc-write path itself is proven by Phase 6's `contact-doc-smoke`. Existing accounts pick up the new instruction text on next meeting-prep reseed; new accounts get it from the template.
### Phase 8 — Prompt-driven personalization tools (chat agent)
**Goal:** An AE personalizes the Strategist by prompting the main chat agent, which mutates the catalog and module subagents.
- [x] Add pure `applyCatalogPatch(catalog, ops)` + DB-facing `patchStrategicCatalog(db, aopId, ops)` ([overview-instance.ts](apps/server/src/services/crm/overview-instance.ts)) — ops: `add` (upsert by key), `remove`, `setVisibleWhen` (null clears), `setRender`, `setOwner` (null clears), `reorder`; persists `displayConfig.strategicOverview.items`.
- [x] Add `configure-strategist` tool ([configureStrategistTool.ts](apps/server/src/mastra/tools/config/configureStrategistTool.ts)) wrapping `patchStrategicCatalog`; input `{ aopId?, ops[] }` (aopId falls back to the conversation context). Rich description maps user asks ("show a battlecard for enterprise deals", "hide Forecast") → ops.
- [x] Register `configure-strategist` in **both** tool maps of [chat-agent.ts:530](apps/server/src/mastra/agents/chat-agent.ts),[555](apps/server/src/mastra/agents/chat-agent.ts).
- [ ] **Deferred — `manage-subagent`** (create/enable/disable a module subagent). `createSubagent`/`updateSubagentHeader` are tRPC-route-bound flows (slug + doc write + playbook `<ref>` insertion); exposing them cleanly as a chat tool is its own change. `configure-strategist` already delivers the core personalization (fields, embedded docs, conditions, render, ownership on the catalog). Enabling/disabling an existing subagent is also achievable today via its doc frontmatter.
**Tests:**
- [x] Unit: `applyCatalogPatch` — upsert-by-key, remove, set visibleWhen/render/owner (+ null-clear), unknown-key no-op, sorted — [overview-instance.test.ts](apps/server/src/services/crm/__tests__/overview-instance.test.ts) (10 passed total).
- [x] Live: `configure-strategist-smoke.ts` (as jesse) — patch adds an always-visible `smoke_doc` (→ materializes in the instance, `{id}` resolved) and a `headcount>=999999`-gated item (→ correctly absent); AOP `displayConfig` + conversation `overview_items` fully restored (no residue). PASS.
- [x] `pnpm exec vitest run src/services/crm/__tests__/overview-instance.test.ts` (10) + `pnpm deps:check` clean (1224 modules); touched files typecheck clean.