# Sales Work Queue Implementation Plan

> **For agentic workers:** Steps use checkbox (`- [ ]`) syntax for tracking. Execute phase by phase; run the suite after every phase.

**Goal:** Make one server-side source own every number about follow-up work, so the Hub, Action Items, Zoom AI Agent and the analysis AI stop answering the same question three different ways.

**Architecture:** A `SalesWorkQueue` service at lead grain becomes the only place a follow-up count, a task list, an owner, a schedule bucket or a confirmed-opportunity figure is derived. Existing surfaces stop computing and start reading. `followed_up_at` is retired as the authority for Zoom. A new `scheduled_for` column (AI-suggested, human-confirmed, provenance recorded) turns the open-task pool into a real daily checklist. The analysis AI's cross-meeting memory switches from "steps the AI once proposed" to "what actually happened".

**Tech Stack:** Laravel 13 · Inertia · Vue 3 · Tailwind v4 · PHPUnit (`petav3_testing`) · Vitest + happy-dom · Pint (psr12) · Herd PHP 8.4

**Branch:** `dev-chen`, local only. No push, no PR.

---

## Why this is the right next step (verified, not assumed)

Measured on the dev database, 2026-08-06:

| Surface | Number shown | Unit it actually counts |
|---|---|---|
| Zoom AI Agent — "N follow-ups I flagged are still open" | 3 | **meetings** |
| Zoom AI Agent — "Follow-ups open" tile | 3 | **meetings** |
| Zoom AI Agent — "Promises kept" | 0 / 3 | **meetings** (`followed_up_at`) |
| Action Items / My Checklist | 6 open, 1 done | **`lead_action_items` rows** |
| Action plans | 1 draft, 2 approved | **plans** |

Those 3 meetings contain **12** AI steps, which became **7** approved task rows.

The root cause is sharper than "the counts are inconsistent":

**`followed_up_at` is written by exactly one thing — the manual toggle at `RecordingsController:597`.** Completing every step of an approved action plan never touches it. A salesperson can finish all 7 tasks and the AI Agent will still report 3 open follow-ups and 0 promises kept, forever. It is not a stale display; it is a metric with no writer.

That is why an aggregation layer on top is not enough — the unified source has to *take over* the metric, which is what decision 2 below settles.

---

## Decisions taken (2026-08-06)

1. **Scope: everything.** P0 unified source, P1 opportunity workbench, P1 `scheduled_for` daily checklist, P2 AI memory. Team AI Chat (P3) stays out — it is the consumer that only becomes safe once this lands.
2. **`followed_up_at` retires as authority.** `followThrough()` counts task rows from the queue. The column stays readable for legacy Zoom rows and keeps its manual toggle, but nothing derives a headline number from it any more.
3. **Zoom only.** Phone Call and Showroom F2f keep their current `followThrough()` shape. They have no action plans and no per-step assignment, so pointing them at a task-row queue would produce an emptier number, not a truer one. Revisit when they gain plans.
4. **AI suggests the date, a human confirms it** — and the provenance is recorded, mirroring `priority_source`. See B1 for why approval alone is not confirmation.

### Deliberately NOT in scope

Carried forward from the existing evidence gate, unchanged: no conversion percentages, no action-uplift numbers, no auto-approval, no auto-assignment, no prompt self-modification from feedback, no analyst revenue-share logic.

---

## The naming collision — settled without a rename (2026-08-06)

Zoom keeps its **Action Items** page at `/manage/zoom/actions`. Chen's call: no Zoom rename.

That leaves two pages sharing a name, so the disambiguation moves to **where each number links**, which is the part that actually misleads. A count must land on the page that can act on *that* count:

| Number | Grain | Links to | Why |
|---|---|---|---|
| open follow-up steps | task rows | `/manage/action-items` | the workbench is where a step is worked |
| plans awaiting review | draft plans | `/manage/zoom/actions` | the review modal is mounted there (`Pages/Manage/Zoom/Actions/Index.vue`) |

Without this split, Phase A's unification actively makes things worse: the single `follow_ups` next-step today would carry the new, correct "6 open steps" to a page that only lists 3 meetings. A truer number behind a wrong link reads as a bug in the number.

**Link text carries the grain, since the page names no longer can** — "6 follow-up steps waiting on your team", "1 action plan waiting for your approval". A reader must not have to know which *Action Items* they are about to land on.

---

## File Structure

**New**

| Path | Responsibility |
|---|---|
| `src/Lead/Services/SalesWorkQueue.php` | The single source. Lead-grain rows: tasks, schedule buckets, opportunity, owners. |
| `src/Lead/Services/LeadWorkHistoryContextBuilder.php` | The bounded "what already happened on this lead" snapshot for the analysis AI. |
| `database/migrations/2026_08_06_400001_add_scheduled_for_to_lead_action_items.php` | `scheduled_for` + `scheduled_source`. |
| `resources/js/Components/Leads/WorkQueueRow.vue` | One lead/opportunity row on the workbench. |
| `resources/js/utils/scheduleBuckets.js` | Today / Overdue / Upcoming / Unscheduled classification, shared by card and panel. |

**Modified**

| Path | Change |
|---|---|
| `app/Http/Controllers/Concerns/BuildsActionPlanChecklist.php` | Becomes a thin adapter over `SalesWorkQueue`; its query methods move into the service. |
| `app/Http/Controllers/Manage/DashboardController.php` | Hub card + `teamChecklist()` read the queue. |
| `app/Http/Controllers/Manage/ActionItemsController.php` | The workbench; grouped by lead/opportunity. |
| `app/Http/Controllers/Manage/Zoom/ZoomAiAgentController.php` | `followThrough()` / `narrative()` / `nextSteps()` / `plainTiles()` / `valueStrip()` / `teamRows()` read the queue. |
| `app/Jobs/Ai/AnalyzeZoomMeeting.php` | `priorMeetingContext()` reads outcomes, not proposals. |
| `src/Lead/LeadActionItem.php` | `SCHEDULE_SOURCE_*` constants, `scheduled_for` cast. |
| `src/Zoom/Services/ZoomActionPlanDraftService.php` | Carries `suggested_scheduled_for` through the draft. |
| `src/Zoom/Repositories/ZoomMeetingActionPlanRepository.php` | Writes `scheduled_for` + `scheduled_source` at approval. |
| `resources/js/Components/ActionPlanChecklistPanel.vue` | Schedule buckets; date field. |
| `resources/js/Pages/Manage/ActionItems.vue` | Workbench layout. |

---

## Phase A — one source of truth (P0)

### Task A1: `SalesWorkQueue` with a contract test

**Files:** Create `src/Lead/Services/SalesWorkQueue.php`, `tests/Feature/Lead/SalesWorkQueueTest.php`

The service owns what `BuildsActionPlanChecklist::openPlanItemsByLead()` and `completedPlanItemsQuery()` do today, plus the counts every consumer currently derives for itself.

```php
public function forViewer(User $viewer, SalesWorkQueueScope $scope): Collection
```

`SalesWorkQueueScope` is a small value object (`src/Lead/Services/SalesWorkQueueScope.php`) rather than an options array, so a caller cannot silently mean "team" by omitting a key: `::mine(User $viewer)`, `::team()`, plus `withCompleted()`, `assignedTo(array $userIds)`, `since(CarbonInterface)`.

Row shape (grain = **Lead**):

```
lead        { uuid, name, phone|null }          phone omitted entirely on team scope
opportunity { project, engagement_retired, value, value_estimated, shows_value,
              interest, interest_label, buying_stage, buying_stage_label,
              data_completeness } | null        null unless a human confirmed the link
plans       [ { plan_uuid, meeting_uuid, meeting_date, progress{done,total},
                open_items[], completed_items[] } ]
tasks       { open, done, total }
schedule    { today, overdue, upcoming, unscheduled }   (zeroes until Phase B)
owners      [ { uuid, name } ]                  team scope only
```

Rules carried over verbatim from the trait — each was learned from a real defect, so none may be re-derived:

- `LeadVisibility::apply()` on the lead relation. **The assignee pivot is membership, not authorisation** — a reassigned lead otherwise leaks its name and step text to the previous owner.
- Eager-load `sourcePlan.meeting.opportunityLink.engagement.project.catalogProject` and `.engagement.booking`; **never join** (GUIDELINES §14 — `Engagement::booking()` is `latestOfMany`).
- Orphan leads (purged lead, surviving item) are dropped, not 500'd.
- Priority ordering: `CASE WHEN priority IS NULL THEN 1 ELSE 0 END, priority`, then `id`.
- Commission is Super-Admin-only (`shows_value`); the project name is not.
- Opportunity is null unless `STATUS_CONFIRMED`. A suggestion is not a fact.

- [ ] **A1.1** Write `SalesWorkQueueTest` first, covering: visibility exclusion, orphan-lead survival, priority ordering, `shows_value` false for a sales agent, opportunity null for an unconfirmed link, `tasks.open`/`done` matching the rows returned, and a query-count assertion proving eager loading holds at N plans.
- [ ] **A1.2** Run it — every test fails (class missing).
- [ ] **A1.3** Implement the service by moving the trait's query bodies across unchanged.
- [ ] **A1.4** Run — green. `herd php artisan test --filter=SalesWorkQueueTest`
- [ ] **A1.5** Commit: `feat(sales): add the sales work queue as one source for follow-up work`

### Task A2: Hub and Action Items read the queue

**Files:** Modify `BuildsActionPlanChecklist.php`, `DashboardController.php`, `ActionItemsController.php`

The trait keeps its display-shaping methods (`toMyTaskArray`, `toCompletedItemArray`, the label helpers) and loses its query methods. Both controllers inject `SalesWorkQueue`.

- [ ] **A2.1** Existing `ActionPlanChecklistTest` / dashboard tests must pass **unchanged** — that is the proof the payload contract did not move.
- [ ] **A2.2** Delete `openPlanItemsByLead()`, `completedPlanItemsQuery()`, `groupByPlan()` from the trait.
- [ ] **A2.3** Run the full Dashboard + ActionItems suites.
- [ ] **A2.4** Commit: `refactor(sales): read the hub checklist from the work queue`

### Task A3: Zoom AI Agent reads the queue; `followed_up_at` retires

**Files:** Modify `ZoomAiAgentController.php`, `tests/Feature/Manage/Zoom/ZoomAiAgentTest.php`

`followThrough()` changes unit from meetings to task rows:

```php
// BEFORE: meetings with AI items, closed = meetings whose followed_up_at is set
// AFTER : task rows from SalesWorkQueue, closed = status DONE
[
    'with_items' => $rows->sum(fn ($r) => $r['tasks']['total']),
    'open'       => $rows->sum(fn ($r) => $r['tasks']['open']),
    'closed'     => $rows->sum(fn ($r) => $r['tasks']['done']),
    'closure_rate' => ...,
    'avg_close_days' => avg(done_at - plan.approved_at),
]
```

On today's data this turns "3 open / 0 promises kept" into "6 open / 1 of 7 kept" — numbers that move when a salesperson works.

`avg_close_days` measures from **plan approval**, not meeting start: the clock a salesperson is accountable for starts when the work is handed to them.

- [ ] **A3.1** Write the failing test: seed one approved plan with 3 items, complete 1, assert `open === 2` and `closed === 1` **while the meeting's `followed_up_at` stays null**. This is the regression that proves the metric is now wired to real work.
- [ ] **A3.2** Run — fails (returns meeting counts).
- [ ] **A3.3** Implement. Rewrite the `followThrough()` docblock to record that `followed_up_at` is legacy-only for Zoom and why.
- [ ] **A3.4** Run the Zoom AI Agent suite.
- [ ] **A3.5** Commit: `fix(zoom): count follow-ups in task rows, not meetings`

### Task A3b: send each number to the page that can act on it

**Files:** Modify `ZoomAiAgentController::nextSteps()`, `resources/js/Components/AiAgent/ValueStrip.vue`

Split the single `follow_ups` next-step in two, per the table above:

- `open_tasks` → `/manage/action-items` — "N follow-up steps waiting on your team"
- `plans_awaiting_review` → `/manage/zoom/actions` — "N action plan(s) waiting for your approval", shown only when a DRAFT plan exists (today: 1)

**Also rename the ValueStrip tile: "Promises kept" → "Follow-ups completed."** An action item is now an AI suggestion or a human addition — not necessarily anything the customer was promised. "Promises kept" states an inference as fact, which is the same evidence rule that keeps conversion percentages out of this product. The sub-label goes with it for the same reason: "follow-ups I flagged that your team closed" claims AI authorship of every row, which stopped being true the moment reviewers could add steps → "follow-up steps your team completed".

> **This tile is shared.** `Components/AiAgent/ValueStrip.vue` is mounted by Phone Call and Showroom F2f too, so both labels change on all three channels. That is the intended outcome, not spillover — an AI-suggested next step is not a customer promise on any of them. The queue itself stays Zoom-only per decision 3; only the wording travels.

- [ ] **A3b.1** Write the failing tests: a draft plan produces a `plans_awaiting_review` step pointing at `/manage/zoom/actions`; open tasks produce an `open_tasks` step pointing at `/manage/action-items`; zero drafts produces no review step.
- [ ] **A3b.2** Run — fails. Implement both steps and the label change.
- [ ] **A3b.3** Run the Zoom, Calls and F2f AI Agent suites (the shared tile touches all three).
- [ ] **A3b.4** Commit: `fix(zoom): link each follow-up number to the page that can act on it`

### Task A4: the invariant that keeps them aligned

**Files:** Create `tests/Feature/Sales/WorkQueueIsTheOnlySourceTest.php`

A test that fails when a new surface starts deriving its own count: assert no controller under `app/Http/Controllers/Manage` outside the queue's own consumers references `followed_up_at` in a headline aggregate, and that the Hub, workbench and AI Agent report the **same** open total for the same viewer in one seeded scenario.

The second half is the one with teeth — it compares three real payloads rather than grepping.

- [ ] **A4.1** Write it. Run — expect green (A2/A3 just made it true); temporarily break `followThrough()` to prove it goes red, then restore. Record the mutation result in the commit body.
- [ ] **A4.2** Commit: `test(sales): pin the hub, workbench and agent to one open-task total`

### Task A5: one source of truth is not one field of view

**Files:** Create `tests/Feature/Sales/SalesWorkQueueVisibilityTest.php`

A4 pins every surface to the same *derivation*. This pins the fact that the same derivation must still return **different rows to different people** — the failure mode a unification invites, and the one that would be worst to discover in front of the boss.

The scenario: two leads, two salespeople, one Super Admin.

| Assertion | Guards against |
|---|---|
| Sales A sees only tasks assigned to A **on leads A may see** | the assignee pivot being mistaken for authorisation |
| Sales A sees nothing after their lead is reassigned away — including the lead NAME and the step TEXT | the exact leak the trait's `LeadVisibility::apply()` comment was written for |
| Sales A cannot reach `::team()` scope at all | scope selection being a display concern rather than an authorisation one |
| Super Admin sees both salespeople's rows | over-correction — a scoped queue that scopes the manager too |
| Commission (`shows_value`) is true for the Super Admin, false for both agents, on the same lead | the disclosure split surviving the move into the service |
| Team-scope rows carry no `phone` key at all — absent, not null | a later edit reintroducing contact data on the org-wide list |

`LeadVisibility::apply()` is a **no-op at LEVEL_ALL**, so a Super Admin passing through it proves nothing about an agent. Both roles are asserted explicitly; neither is inferred from the other.

- [ ] **A5.1** Write all six assertions. Run — they must pass against the A1 implementation.
- [ ] **A5.2** Prove they have teeth: drop the `whereHas('lead', …LeadVisibility…)` clause and confirm the reassignment case goes red; restore. Record it in the commit body.
- [ ] **A5.3** Commit: `test(sales): pin queue visibility to the viewer's role and leads`

---

## Phase B — the real daily checklist (P1)

### Task B1: `scheduled_for` + provenance

**Files:** Create the migration; modify `src/Lead/LeadActionItem.php`

```
scheduled_for     DATE     nullable, indexed
scheduled_source  UNSIGNED INT nullable   1 = AI suggested, 2 = human set
```

**Why `scheduled_source` and not just the date.** The same argument already written into `prioritySourceLabel()`: approving a plan without touching a field is not evidence a human chose that value. Without provenance a salesperson reads an AI-guessed due date under an "Approved" header as a commitment a colleague made for them. Nullable date means "unscheduled" and stays a first-class state — the pool does not become a deadline machine.

- [ ] **B1.1** Write the model test for the constants and cast. Run — fails.
- [ ] **B1.2** Write the migration. Hard-code the literals, not the constants (GUIDELINES §7 — `scripts/check-migration-constants.php` guards this).
- [ ] **B1.3** `herd php artisan migrate` then verify on a scratch database: `DB_DATABASE=petav3_scratch herd php artisan migrate:fresh`.
- [ ] **B1.4** Run — green. Commit: `feat(sales): add a confirmable schedule date to action items`

### Task B2: the AI proposes a date

**Files:** Modify `src/Conversation/ConversationAnalysis.php`, `ZoomActionPlanDraftService.php`

`recommended_actions` gains an optional `suggested_scheduled_for` (ISO date). It goes through the same `normalize()` sanitiser as `priority` and `action_type` — an unparseable or past date clamps to null, never to today.

The prompt asks for a date **only where the transcript justifies one** ("customer said Saturday"), and to omit it otherwise. A model that dates everything produces a checklist that is overdue by Tuesday.

- [ ] **B2.1** Write normalize() tests: valid date passes; garbage → null; past date → null; absent → null.
- [ ] **B2.2** Run — fails. Implement. Run — green.
- [ ] **B2.3** Commit: `feat(ai): let the analysis suggest a date where the customer named one`

### Task B3: the reviewer confirms it

**Files:** Modify the review modal + `ZoomMeetingActionPlanRepository.php`

Each draft row gets a date input pre-filled with the AI's suggestion, labelled as suggested. On approval: an untouched suggestion writes `scheduled_source = AI`; an edited or cleared one writes `HUMAN`.

- [ ] **B3.1** Repository test: approving with an untouched date records AI; edited records HUMAN; cleared writes null date + HUMAN.
- [ ] **B3.2** Run — fails. Implement. Run — green.
- [ ] **B3.3** Vitest for the date field, including the "AI suggested" affordance.
- [ ] **B3.4** Commit: `feat(zoom): confirm or override the suggested date at approval`

### Task B4: buckets in the queue

**Files:** Modify `SalesWorkQueue.php`, create `resources/js/utils/scheduleBuckets.js`

Today / Overdue / Upcoming / Unscheduled, computed **server-side in the viewer's timezone**, with the JS util only classifying what the server already labelled — two implementations of "today" across a timezone boundary is a bug that only shows up at 8am.

- [ ] **B4.1** Test the four buckets including the boundary (a task dated today is Today, not Overdue). Run — fails. Implement. Run — green.
- [ ] **B4.2** Commit: `feat(sales): bucket queue tasks by schedule`

### Task B5: the sign-in greeting becomes Today + Overdue

**Files:** Modify `ActionPlanChecklistSection.vue`, `ActionPlanChecklistPanel.vue`

`autoOpen` opens on Today + Overdue only. Everything else stays reachable by tab.

**Also fixes the misleading label Chen found:** the card currently shows the Zoom **meeting date** next to a calendar icon, which reads as a due date. Either drop the icon or label it "Met 14 Jul" — a calendar glyph next to a bare date is a promise the data does not make.

- [ ] **B5.1** Vitest: autoOpen with only Upcoming tasks does not open; with one Overdue it does.
- [ ] **B5.2** Implement both. Run Vitest — green.
- [ ] **B5.3** Commit: `feat(sales): greet salespeople with today and overdue only`

### Task B6: Team Tasks filters by date and person

**Files:** Modify `DashboardController::teamChecklist()`, `TeamChecklistRequest`

- [ ] **B6.1** Request test: unknown assignee uuid rejected; date filter narrows.
- [ ] **B6.2** Implement. Run — green. Commit: `feat(sales): filter team tasks by date and owner`

> **Known limitation, carried forward:** the assignee dropdown still lists only people from loaded pages. Out of scope here; recorded so it is not mistaken for done.

---

## Phase C — the opportunity workbench (P1)

### Task C1: opportunity dimensions into the queue

**Files:** Modify `SalesWorkQueue.php`; read from `ZoomOpportunitySignals`

`interest`, `buying_stage`, `data_completeness` come from the **same** `ZoomOpportunitySignals` service the Hub cards use. Not a second derivation — that is precisely how the Hub and the workbench would start disagreeing about the same lead.

- [ ] **C1.1** Test that a queue row and `ZoomPriorityOpportunitiesController::toCardRow()` report identical interest / stage / completeness for one meeting. Run — fails. Implement. Run — green.
- [ ] **C1.2** Commit: `feat(sales): carry opportunity signals on queue rows`

### Task C2: Team Tasks grouped by lead/opportunity

**Files:** Modify `resources/js/Pages/Manage/ActionItems.vue`, create `WorkQueueRow.vue`

One row per lead: AI-read interest, buying stage, confirmed product, estimated commission (Super Admin only), data completeness, open/done progress, per-task owner.

- [ ] **C2.1** Vitest for the row: commission hidden without `shows_value`; "Retired" shown for a trashed engagement; no opportunity block when unconfirmed.
- [ ] **C2.2** Implement. Run — green.
- [ ] **C2.3** Commit: `feat(sales): group the workbench by lead and opportunity`

### Task C3: contact affordances

**Files:** Modify `WorkQueueRow.vue`

Call · WhatsApp · open Lead · open Zoom.

**WhatsApp goes to the internal inbox, not `wa.me`** — this is the P0 correction from the 08-06 review: `LeadCell`'s WhatsApp affordance is an internal route, and "reuse the shared composable" is what would have silently changed two unrelated tables.

**Phone appears on My Tasks rows only.** Team rows deliberately carry no phone (`toTeamItemArray`), and that is not an oversight to fix here.

- [ ] **C3.1** Vitest: team-scope row renders no phone affordance.
- [ ] **C3.2** Implement. Run — green. Commit: `feat(sales): add contact actions to workbench rows`

---

## Phase D — the AI stops repeating finished work (P2)

### Task D1: the history snapshot

**Files:** Create `src/Lead/Services/LeadWorkHistoryContextBuilder.php`

Bounded, structured, no new PII:

```
open_tasks         [ { body, priority, action_type, scheduled_for } ]   cap 10
recently_completed [ { body, done_at } ]                               cap 10, 90 days
rejected_reasons   [ { body, reason } ]                                cap 5, disagree only
```

Everything is quoted DATA, framed untrusted by the caller, `JSON_HEX_TAG` applied — the same control the other three context builders carry, hand-copied per consumer rather than shared, deliberately.

- [ ] **D1.1** Test the caps, the 90-day window, the exclusion of a lead the meeting does not belong to, and that no email or phone appears in the payload. Run — fails. Implement. Run — green.
- [ ] **D1.2** Commit: `feat(ai): build a bounded work-history snapshot per lead`

### Task D2: wire it into the analysis

**Files:** Modify `app/Jobs/Ai/AnalyzeZoomMeeting.php`

`priorMeetingContext()` today emits the prior meetings' summaries plus `Agreed next steps: …` read from `ai_analysis` — **the steps the AI once proposed, with no completion status attached**. Being told a step was agreed and not told it was done is what biases the model toward re-proposing it. That is the mechanism behind the real complaint ("the customer already got the floor plans last week").

New preamble adds the outcomes and three rules:

- Do not re-suggest completed work unless this meeting explicitly reopens it.
- Do not create a step that duplicates an existing open task.
- A task type the salesperson has rejected repeatedly is flagged for the reviewer, not silently dropped.

- [ ] **D2.1** Write the failing test: a lead with a completed "send floor plans" task produces a preamble containing it under completed, and the rules text. Run — fails.
- [ ] **D2.2** Implement. Run — green.
- [ ] **D2.3** Commit: `fix(ai): tell the analysis what was already done, not just what was promised`

> **Honesty note for the demo:** this reduces duplicate suggestions; it cannot guarantee their absence. It is a prompt-level constraint over a bounded snapshot, not an enforced dedup. If a guarantee is wanted, the place for it is a server-side dedup at draft time — a separate task, not this one.

### Task D3: surface repeat rejections to the reviewer

**Files:** Modify the review modal payload

A step whose type has been disagreed with 3+ times on this lead gets a marker in the review modal.

- [ ] **D3.1** Test the threshold. Run — fails. Implement. Run — green.
- [ ] **D3.2** Commit: `feat(zoom): flag repeatedly rejected step types at review`

---

## Verification before calling it done

- [ ] Full suite: `herd php artisan test` — compare failures against the recorded baseline (107 pre-existing on this branch), assert **no new** failures.
- [ ] `npm run test` (Vitest) green.
- [ ] `./vendor/bin/pint --test`.
- [ ] `DB_DATABASE=petav3_scratch herd php artisan migrate:fresh` — proves the new migration on an empty database, which `migrate` on an up-to-date machine cannot.
- [ ] Browser acceptance as Super Admin **and** sales agent: the Hub card, the workbench and the Zoom AI Agent must show the **same open total**; complete one task and watch all three move together. That single check is the whole point of the phase.
- [ ] **Click both next-step links** and confirm each lands somewhere whose contents match the number that was clicked — the task count on the workbench, the review count on the Zoom page. A count that survives the click is the acceptance test for A3b.
- [ ] Confirm the demo reset script still restores a reviewable draft.

## Self-review of this plan

- **Spec coverage:** P0 → Phase A. P1 workbench → Phase C. P1 daily checklist → Phase B. P2 AI memory → Phase D. P3 team AI Chat → explicitly deferred, per Chen's own sequencing.
- **Type consistency:** the row shape in A1 is the shape B4 extends (`schedule`), C1 extends (`opportunity`) and D1 reads. `SalesWorkQueueScope` is named identically in A1, A2, A5 and B6.
- **Naming:** settled — no Zoom rename. Two pages keep the name *Action Items*; A3b makes the link text and link target carry the grain instead. Nothing later in the plan renames a route or a nav label.
- **The two invariants are different, and both are needed:** A4 says every surface derives the number the same way; A5 says the same derivation still shows different people different rows. A4 alone would pass a queue that leaked every lead to everyone.
