feat: Chief of Staff email pipeline with Slack approval flow#1992
feat: Chief of Staff email pipeline with Slack approval flow#1992nickleeke wants to merge 26 commits intoelie222:mainfrom
Conversation
Architecture spec, skill source (SKILL.md + references), design specification, and 21-task implementation plan for the Chief of Staff email processing pipeline. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…pipeline Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements a thin HTTP wrapper for the Acuity Scheduling REST API with Basic Auth, exponential backoff on 429 rate limits (max 3 retries), and typed error handling via AcuityApiError. Includes 6 Vitest tests covering auth, error cases, retry behavior, and POST body serialization. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements prefix-parser (HARD_BLOCK/SOFT/INFORMATIONAL via ~, FYI:) and day-protection (Tuesday always blocked, Friday blocked for non-VIPs) with full vitest coverage (11 tests, all passing). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion Queries all 6 Google Calendars for conflicts with 15-min buffers, applies prefix conventions (~ soft, FYI: ignored), enforces day protection short-circuit, treats Nutrition/Workout as always soft, and always hard-blocks RMS Work events. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Defines 7 tools using the AI SDK tool() function with Zod schemas: check_calendar, check_acuity_availability, get_client_history, book_appointment, reschedule_appointment, cancel_appointment, and create_gmail_draft (with signature appended). Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements processEmailWithClaude() using generateText with maxSteps:10 for multi-turn tool calling. Claude is instructed to return JSON in its final message which is parsed into a typed CosEngineResponse. Includes 5 unit tests covering valid JSON parsing, call signature, email content inclusion, invalid JSON error handling, and draft responses. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements action handlers (handleApprove, handleEdit, handleEditSubmit, handleReject) and the Next.js API route at /api/chief-of-staff/slack/interactions that verifies Slack signatures, routes block_actions and view_submission events, and delegates to the appropriate Gmail + Prisma + Slack operations. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implements the Gmail Pub/Sub webhook endpoint and async pipeline processor that ties together pre-filtering, Claude processing, venture detection, and Slack posting for the Chief of Staff bot. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Verifies pre-filter, venture detection, day protection, prefix parser, and shipping parser work together correctly using real inputs. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… checklist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The cross-env NODE_OPTIONS=16GB was only applied to prisma migrate deploy due to the && starting a new shell context, leaving next build with default memory limits and causing OOM at ~480MB. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The standalone Next.js server was OOMing at ~475MB on Railway because no heap limit was configured for the runtime container. Defaults to 1GB but respects any NODE_OPTIONS set via Railway env vars. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
|
@nickleeke is attempting to deploy a commit to the Inbox Zero OSS Program Team on Vercel. A member of the Team first needs to authorize it. |
There was a problem hiding this comment.
30 issues found across 61 files
Prompt for AI agents (unresolved issues)
Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.
<file name="apps/web/utils/chief-of-staff/pre-filter.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/pre-filter.ts:46">
P2: extractDomain parses the raw From header with /@([^>]+)/, which can capture the wrong substring when the display name or comments contain '@' (valid RFC 5322 format). This can mis-parse senderDomain and break allowlist/blocklist routing. Consider extracting the address portion first and then taking the last '@' domain.</violation>
<violation number="2" location="apps/web/utils/chief-of-staff/pre-filter.ts:61">
P2: End-anchored shipping domain regexes are tested against the raw From header, so common formatted headers like "Name" <tracking@ups.com> end with ">" and won’t match /ups\.com$/i. This causes shipping senders to be missed unless the subject keywords happen to match.</violation>
<violation number="3" location="apps/web/utils/chief-of-staff/pre-filter.ts:79">
P2: Allow/block domain comparisons only lowercase configured values; they don’t trim whitespace or strip a leading '@', so common config formats like "@example.com" won’t match senderDomain and rules silently miss.</violation>
</file>
<file name="apps/web/utils/chief-of-staff/system-prompt.test.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/system-prompt.test.ts:5">
P2: Mock target mismatch: implementation imports "node:fs", so mocking "fs" won't intercept reads and the test may hit real files.</violation>
</file>
<file name="apps/web/utils/chief-of-staff/engine.test.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/engine.test.ts:107">
P2: Test claims to verify correct model/system prompt but only checks key existence, missing assertions for `model` and concrete `system` value.</violation>
</file>
<file name="apps/web/.env.example">
<violation number="1" location="apps/web/.env.example:243">
P2: Duplicate CRON_SECRET entry in .env.example can override the earlier value; the later blank value may disable/break cron auth when loaded.</violation>
</file>
<file name="skill-source/references/calendar-intelligence.md">
<violation number="1" location="skill-source/references/calendar-intelligence.md:127">
P2: The decision tree allows FYI/~ prefixes to override blocking, but the Special Rules section says RMS Work events are always hard blocks regardless of prefix. This contradiction could cause implementers to ignore RMS conflicts that should block scheduling.</violation>
</file>
<file name="apps/web/utils/chief-of-staff/slack/blocks.test.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/slack/blocks.test.ts:199">
P2: The urgent-marker assertion is tautological because the subject already contains "URGENT", so the regex passes even if the builder stops adding an urgency marker. This weakens coverage for the urgency indicator.</violation>
</file>
<file name="apps/web/app/api/chief-of-staff/webhook/route.ts">
<violation number="1" location="apps/web/app/api/chief-of-staff/webhook/route.ts:9">
P2: Webhook token validation is unconditionally enforced, so empty/unset verification token mode cannot work and valid Pub/Sub pushes may be rejected.</violation>
<violation number="2" location="apps/web/app/api/chief-of-staff/webhook/route.ts:18">
P2: Malformed Pub/Sub payloads will throw during JSON parsing before the 200 response is returned, defeating the intended immediate acknowledgment and causing retry loops for bad payloads.</violation>
</file>
<file name="apps/web/utils/chief-of-staff/acuity/client.test.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/acuity/client.test.ts:18">
P2: Fake timers are enabled without guaranteed cleanup; if the test fails before `vi.useRealTimers()`, fake timers leak into subsequent tests.</violation>
</file>
<file name="apps/web/utils/chief-of-staff/signatures/fetcher.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/signatures/fetcher.ts:14">
P2: Empty-string signatures are treated as cache misses because the cache-hit check requires `signatureHtml` to be truthy, causing repeated Gmail fetches for accounts with no signature.</violation>
</file>
<file name="go-live-checklist.html">
<violation number="1" location="go-live-checklist.html:695">
P3: `loadState` parses localStorage JSON without error handling, so malformed persisted data can throw and abort checklist initialization (progress bar/section state never initializes).</violation>
</file>
<file name="apps/web/config/system-prompt.md">
<violation number="1" location="apps/web/config/system-prompt.md:213">
P1: Scheduling is allowed to proceed when calendar conflict checks fail, which can cause bookings/reschedules to execute using incomplete availability data.</violation>
<violation number="2" location="apps/web/config/system-prompt.md:214">
P2: Prompt instructs drafting English responses for non‑English emails, which conflicts with the policy to reply in the language of the latest thread message.</violation>
</file>
<file name="apps/web/utils/chief-of-staff/jobs/retry-failed.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/jobs/retry-failed.ts:49">
P2: retryFailedEmails never retries processing; it only increments retryCount, so failed emails will reach dead_letter without any reprocessing attempt.</violation>
</file>
<file name="apps/web/utils/chief-of-staff/vip/detector.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/vip/detector.ts:20">
P1: `checkVipStatus` uses raw email strings for unique lookups and dedupe without canonicalization, which can miss cache/group matches and misclassify VIP status.</violation>
</file>
<file name="apps/web/utils/chief-of-staff/calendar/day-protection.test.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/calendar/day-protection.test.ts:11">
P2: Test dates are parsed in the local timezone, but isDayProtected evaluates in America/Chicago, so the expected weekday can shift in different environments.</violation>
</file>
<file name="apps/web/utils/chief-of-staff/calendar/day-protection.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/calendar/day-protection.ts:13">
P2: Intl.DateTimeFormat().format throws on invalid Date; isDayProtected doesn’t guard, so malformed startTime/endTime strings can crash scheduling instead of returning a safe result.</violation>
</file>
<file name="skill-source/SKILL.md">
<violation number="1" location="skill-source/SKILL.md:197">
P2: Prompt instructions conflict on sending vs drafting scheduling confirmations, creating ambiguous and potentially unsafe assistant behavior.</violation>
</file>
<file name="apps/web/utils/chief-of-staff/calendar/checker.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/calendar/checker.ts:81">
P1: Events with no summary are skipped, which can hide real time conflicts and incorrectly mark slots as available.</violation>
</file>
<file name="chief-of-staff-bot-architecture.md">
<violation number="1" location="chief-of-staff-bot-architecture.md:249">
P2: Gmail Pub/Sub setup URL points to `/webhook/gmail`, but the actual webhook handler is `/api/chief-of-staff/webhook`, so following this doc will send Pub/Sub to a non-existent endpoint.</violation>
<violation number="2" location="chief-of-staff-bot-architecture.md:256">
P2: Slack interactivity URL points to `/slack/events`, but the actual handler is `/api/chief-of-staff/slack/interactions`, so Slack interactions will fail if configured per the doc.</violation>
</file>
<file name="apps/web/utils/chief-of-staff/acuity/client.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/acuity/client.ts:61">
P2: Network-level fetch failures are not retried or wrapped in AcuityApiError; any thrown error will exit the loop immediately, bypassing the retry/structured error handling logic.</violation>
</file>
<file name="apps/web/utils/chief-of-staff/tools.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/tools.ts:56">
P2: startTime/endTime strings are parsed without validation; Invalid Date inputs will throw in downstream availability checks (toISOString/Intl.DateTimeFormat).</violation>
<violation number="2" location="apps/web/utils/chief-of-staff/tools.ts:221">
P1: Unsanitized header interpolation allows CRLF header injection when constructing raw Gmail MIME drafts.</violation>
</file>
<file name="apps/web/utils/chief-of-staff/system-prompt.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/system-prompt.ts:16">
P1: System prompt loading relies on `process.cwd()` and an unguarded sync read, which can throw `ENOENT` in deploy/runtime contexts where CWD differs from `apps/web`.</violation>
</file>
<file name="apps/web/utils/chief-of-staff/slack/actions.ts">
<violation number="1" location="apps/web/utils/chief-of-staff/slack/actions.ts:51">
P2: Slack action handlers lack state/idempotency guards, so repeated or conflicting actions can send/delete drafts multiple times and overwrite terminal statuses.</violation>
</file>
<file name="apps/web/app/api/chief-of-staff/webhook/process.ts">
<violation number="1" location="apps/web/app/api/chief-of-staff/webhook/process.ts:213">
P2: lastSyncedHistoryId advances even when message processing fails, so failed Gmail history entries can be skipped permanently without retry.</violation>
<violation number="2" location="apps/web/app/api/chief-of-staff/webhook/process.ts:340">
P2: shippingEvent.calendarEventId is hardcoded to "created" instead of storing the provider’s actual event ID from the insert response, which prevents reliable updates/deletes or reconciliation.</violation>
</file>
Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review.
| | Gmail search returns no results | Report "Inbox clear — nothing new since last check" | | ||
| | Can't determine email category | Default to **Client/Parent** if from a person, **Notification** if automated | | ||
| | Acuity is unreachable | Draft response saying "Let me check my availability and get back to you shortly" | | ||
| | Calendar check fails | Note the gap and proceed with Acuity-only data, flagging the limitation | |
There was a problem hiding this comment.
P1: Scheduling is allowed to proceed when calendar conflict checks fail, which can cause bookings/reschedules to execute using incomplete availability data.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/config/system-prompt.md, line 213:
<comment>Scheduling is allowed to proceed when calendar conflict checks fail, which can cause bookings/reschedules to execute using incomplete availability data.</comment>
<file context>
@@ -0,0 +1,227 @@
+| Gmail search returns no results | Report "Inbox clear — nothing new since last check" |
+| Can't determine email category | Default to **Client/Parent** if from a person, **Notification** if automated |
+| Acuity is unreachable | Draft response saying "Let me check my availability and get back to you shortly" |
+| Calendar check fails | Note the gap and proceed with Acuity-only data, flagging the limitation |
+| Email is in a language other than English | Note the language and attempt categorization; draft response in English with a note to Nick |
+| Email thread (not just single message) | Read the full thread for context before categorizing/drafting |
</file context>
| prisma: PrismaClient, | ||
| ): Promise<VipResult> { | ||
| // 1. Check cache | ||
| const cached = await prisma.vipCache.findUnique({ where: { clientEmail } }); |
There was a problem hiding this comment.
P1: checkVipStatus uses raw email strings for unique lookups and dedupe without canonicalization, which can miss cache/group matches and misclassify VIP status.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/utils/chief-of-staff/vip/detector.ts, line 20:
<comment>`checkVipStatus` uses raw email strings for unique lookups and dedupe without canonicalization, which can miss cache/group matches and misclassify VIP status.</comment>
<file context>
@@ -0,0 +1,82 @@
+ prisma: PrismaClient,
+): Promise<VipResult> {
+ // 1. Check cache
+ const cached = await prisma.vipCache.findUnique({ where: { clientEmail } });
+ if (cached && Date.now() - cached.lastChecked.getTime() < CACHE_TTL_MS) {
+ let groupName: string | null = null;
</file context>
| const events = result.value.data.items ?? []; | ||
|
|
||
| for (const event of events) { | ||
| if (!event.summary) continue; |
There was a problem hiding this comment.
P1: Events with no summary are skipped, which can hide real time conflicts and incorrectly mark slots as available.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/utils/chief-of-staff/calendar/checker.ts, line 81:
<comment>Events with no summary are skipped, which can hide real time conflicts and incorrectly mark slots as available.</comment>
<file context>
@@ -0,0 +1,117 @@
+ const events = result.value.data.items ?? [];
+
+ for (const event of events) {
+ if (!event.summary) continue;
+ const parsed = parseEventPrefix(event.summary);
+ const eventStart = event.start?.dateTime ?? event.start?.date ?? "";
</file context>
| // Build raw MIME message | ||
| const headers: string[] = [ | ||
| `To: ${to}`, | ||
| `Subject: ${subject}`, |
There was a problem hiding this comment.
P1: Unsanitized header interpolation allows CRLF header injection when constructing raw Gmail MIME drafts.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/utils/chief-of-staff/tools.ts, line 221:
<comment>Unsanitized header interpolation allows CRLF header injection when constructing raw Gmail MIME drafts.</comment>
<file context>
@@ -0,0 +1,267 @@
+ // Build raw MIME message
+ const headers: string[] = [
+ `To: ${to}`,
+ `Subject: ${subject}`,
+ `From: ${emailAddress}`,
+ "MIME-Version: 1.0",
</file context>
|
|
||
| function getBasePrompt(): string { | ||
| if (basePromptCache) return basePromptCache; | ||
| const promptPath = path.join(process.cwd(), "config", "system-prompt.md"); |
There was a problem hiding this comment.
P1: System prompt loading relies on process.cwd() and an unguarded sync read, which can throw ENOENT in deploy/runtime contexts where CWD differs from apps/web.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/utils/chief-of-staff/system-prompt.ts, line 16:
<comment>System prompt loading relies on `process.cwd()` and an unguarded sync read, which can throw `ENOENT` in deploy/runtime contexts where CWD differs from `apps/web`.</comment>
<file context>
@@ -0,0 +1,68 @@
+
+function getBasePrompt(): string {
+ if (basePromptCache) return basePromptCache;
+ const promptPath = path.join(process.cwd(), "config", "system-prompt.md");
+ basePromptCache = fs.readFileSync(promptPath, "utf-8");
+ return basePromptCache;
</file context>
| execute: async ({ startTime, endTime, isVip }) => { | ||
| return checkCalendarAvailability({ | ||
| calendarClient: calendarAuth, | ||
| startTime: new Date(startTime), |
There was a problem hiding this comment.
P2: startTime/endTime strings are parsed without validation; Invalid Date inputs will throw in downstream availability checks (toISOString/Intl.DateTimeFormat).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/utils/chief-of-staff/tools.ts, line 56:
<comment>startTime/endTime strings are parsed without validation; Invalid Date inputs will throw in downstream availability checks (toISOString/Intl.DateTimeFormat).</comment>
<file context>
@@ -0,0 +1,267 @@
+ execute: async ({ startTime, endTime, isVip }) => {
+ return checkCalendarAvailability({
+ calendarClient: calendarAuth,
+ startTime: new Date(startTime),
+ endTime: new Date(endTime),
+ isVip,
</file context>
| @@ -0,0 +1,338 @@ | |||
| import { WebClient } from "@slack/web-api"; | |||
There was a problem hiding this comment.
P2: Slack action handlers lack state/idempotency guards, so repeated or conflicting actions can send/delete drafts multiple times and overwrite terminal statuses.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/utils/chief-of-staff/slack/actions.ts, line 51:
<comment>Slack action handlers lack state/idempotency guards, so repeated or conflicting actions can send/delete drafts multiple times and overwrite terminal statuses.</comment>
<file context>
@@ -0,0 +1,338 @@
+ } = params;
+
+ // 1. Look up CosPendingDraft by slackMessageTs
+ const draft = await prisma.cosPendingDraft.findUnique({
+ where: { slackMessageTs },
+ });
</file context>
| } | ||
|
|
||
| // Update lastSyncedHistoryId | ||
| const lastEntry = historyEntries[historyEntries.length - 1]; |
There was a problem hiding this comment.
P2: lastSyncedHistoryId advances even when message processing fails, so failed Gmail history entries can be skipped permanently without retry.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/app/api/chief-of-staff/webhook/process.ts, line 213:
<comment>lastSyncedHistoryId advances even when message processing fails, so failed Gmail history entries can be skipped permanently without retry.</comment>
<file context>
@@ -0,0 +1,561 @@
+ }
+
+ // Update lastSyncedHistoryId
+ const lastEntry = historyEntries[historyEntries.length - 1];
+ if (lastEntry?.id) {
+ await prisma.$executeRaw`
</file context>
| data: { | ||
| messageId, | ||
| emailAccountId: emailAccount.id, | ||
| calendarEventId: "created", |
There was a problem hiding this comment.
P2: shippingEvent.calendarEventId is hardcoded to "created" instead of storing the provider’s actual event ID from the insert response, which prevents reliable updates/deletes or reconciliation.
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At apps/web/app/api/chief-of-staff/webhook/process.ts, line 340:
<comment>shippingEvent.calendarEventId is hardcoded to "created" instead of storing the provider’s actual event ID from the insert response, which prevents reliable updates/deletes or reconciliation.</comment>
<file context>
@@ -0,0 +1,561 @@
+ data: {
+ messageId,
+ emailAccountId: emailAccount.id,
+ calendarEventId: "created",
+ itemDescription,
+ },
</file context>
| function loadState() { | ||
| const saved = localStorage.getItem(STORAGE_KEY); | ||
| if (!saved) return; | ||
| const state = JSON.parse(saved); |
There was a problem hiding this comment.
P3: loadState parses localStorage JSON without error handling, so malformed persisted data can throw and abort checklist initialization (progress bar/section state never initializes).
Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At go-live-checklist.html, line 695:
<comment>`loadState` parses localStorage JSON without error handling, so malformed persisted data can throw and abort checklist initialization (progress bar/section state never initializes).</comment>
<file context>
@@ -0,0 +1,750 @@
+ function loadState() {
+ const saved = localStorage.getItem(STORAGE_KEY);
+ if (!saved) return;
+ const state = JSON.parse(saved);
+ const checkboxes = document.querySelectorAll('.task input[type="checkbox"]');
+ checkboxes.forEach((cb, i) => {
</file context>
Summary
New Files (54 files, ~7000 lines)
apps/web/app/api/chief-of-staff/— webhook, Slack interactions, cron routesapps/web/utils/chief-of-staff/— engine, tools, pre-filter, calendar, Acuity, Slack, VIP, shippingapps/web/config/system-prompt.md— Claude system prompt contentapps/web/prisma/migrations/20260322...— database migrationgo-live-checklist.html— interactive deployment checklistTest plan
🤖 Generated with Claude Code