macsync/handoffs/20260515_macsync-canonical-completion.md
Natalie ff9abc5e3c feat(@mac-sync): update handoff docs with verified paths
Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
2026-05-15 21:17:32 -07:00

27 KiB
Raw Permalink Blame History

MacSync Canonical Completion Plan — 2026-05-15

Closeout status (end of session)

Phase Status
1 — iMessage canonical Swift migration ⚠️ HAPPY-PATH VERIFIED ONLY. APIClient reads data.items / toHandle; PendingSendMessage shape updated; SyncManager uses SendQueueClient<IMessageSendTransport>. Single-row outbound smokes landed in chat.db at 18:39:57 + 18:46:23 PT. Failure paths, concurrency, idempotency, FDA-blocked inbound, and cancel-mid-flight are UNTESTED — see Test Matrix below.
2 — Per-module send-queue stacks 🚧 server-side complete this session for iCal + iMail; iNotes + iReminders already had server-side; Swift Senders + SyncManager wiring for non-iMessage modules is Quinn WIP (uncommitted Sender impls per module)
3 — Plum ↔ apricot tree reconcile Quinn decision still pending; declared plum-canonical for @mac-sync Swift work in this session's memory entry
4 — Retire WG bridge Bridge retired from runtime. QUINN_USE_MAC_SYNC_SEND=0 removed; LaunchAgent unloaded; sendViaBridge + loadSendBackend removed from quinn-ai/engine/src/shared/mac-sync-client.ts; smoke-test.ts single-path. End-to-end canonical-no-bridge smoke landed in chat.db at 18:46:23. Bridge imessage-send.scpt files remain on disk as reference but the process is dead.
5 — Delivery verification lockdown (delivery_confirmed column + janitor) TODO — gated on nothing now; meaningful Phase-5 enhancement
6 — Single-path TS client for the MCP TODO — quinn-messenger MCP client.ts already speaks /admin/send-queue/enqueue, just needs version bump

Test matrix — what's actually verified vs assumed

Two outbound happy-path smokes is not "thoroughly tested." This table is the source of truth for verification coverage.

Scenario Status Evidence / Gap
Immediate enqueue → chat.db arrival verified MacSync consumer smoke 🤖 id=79039fff drained at 18:39:57 PT; chat.db row at 18:39:57
outreach.scheduled_send worker → /admin/send-queue/enqueue → chat.db (via bridge mode) verified end-to-end smoke (bridge mode) 🤖 at 18:42:33 PT (pre-bridge-retire; now historical)
outreach.scheduled_send worker → /admin/send-queue/enqueue → chat.db (MacSync mode, bridge unloaded) verified canonical complete (no bridge) 🤖 at 18:46:23 PT — three layers consistent within 2s
Sender.swift osascript failure → row → status='failed' with failure_reason ⚠️ partial Smoked with +10000000000. Row reported sent (attempts=1, no error) — Messages.app accepts the send locally and writes a chat.db row, so bridge/MacSyncApp see local success. Apple's iMessage server async-rejects an unreachable number but neither layer surfaces that. Hard-failure detection (osascript exception, network down) untested. Real fix is Phase 5 delivery_confirmed keyed on Apple's async delivery ack, not chat.db row presence.
outreach.scheduled_send retry behavior on transient failure untested Worker logic supports retries (attempts column), but a transient failure has not been induced this session.
Multi-row drain (queue backlog) verified 5-row backlog with shared fire_at: all 5 reached status=sent attempts=1; chat.db received echoes 1/5 → 5/5 in order at 19:57:21-22; zero duplicates.
Idempotency on consumer retry untested If MacSyncApp fetches a row, dispatches, crashes before PATCHing result, does the row stay queued and get re-fetched? Does Sender.swift handle the double send?
cancel_scheduled against pending row before fire_at verified Scheduled SHOULD BE CANCELLED 🚫 with fire_at = now+180s, cancelled by intentHash within 2s. Row → status=cancelled, cancellationReason=smoke-cancel-test. chat.db count of "SHOULD BE CANCELLED" = 0 past fire_at.
outreach.scheduled_send row stuck pending past fire_at untested If worker is dead/stalled, do callers notice? Phase 5 janitor would catch this.
MacSyncApp restart mid-drain untested Boot resilience. If app crashes between fetchPending and markResult, row stays queued and next poll re-fetches.
Inbound iMessage sync (chat.db → server) blocked ActivityLog: [error] Full Disk Access required at 18:39:23. Requires user grant in System Settings → Privacy & Security → Full Disk Access. Outbound works without FDA; inbound is dead until granted.
Non-iMessage modules (iCal/iMail/iNotes/iReminders) end-to-end untested Server routes verified to exist; per-module Senders + SyncManager wiring is Quinn WIP. No round-trip smoke.
mcp__quinn-messenger__schedule_message / cancel_scheduled tools from a live Claude session untested in-session MCP server disconnected mid-session; tools were not reloaded after .mcp.json edits. Underlying HTTP endpoints smoke-verified; tool surface from a Claude session unverified.
Schema rename icloud.*macsync.* consistency ⚠️ partial Per-module tables renamed in entity schemas. Legacy iMessage icloud.send_queue table NOT renamed — intentional retention. SEND_QUEUE_CONTRACTS.md per-module table column refers to icloud.<module>_send_queue; should be macsync.<module>_send_queue.
Failed smoke rows from this session linger in DB ⚠️ known outreach.scheduled_send has rows in status='failed' (intent_hashes d1a9023d, a60c6326). Excluded from default list_scheduled. Janitor would clean.

If "do everything" means "exercise everything before declaring done":

  1. Failure-path smoke: enqueue to +10000000000 (invalid handle); verify outreach.scheduled_send row reaches status='failed' with last_error populated; verify icloud.send_queue row reaches status='failed' with failure_reason.
  2. Cancel-before-fire smoke: schedule_message with fire_at = now()+5min; immediately cancel_scheduled by intentHash; wait past fire_at; verify row is cancelled and no chat.db arrival.
  3. Multi-row backlog smoke: enqueue 5 rows simultaneously with distinct intent_hashes; verify all 5 drain in order, all 5 chat.db rows arrive, no double-dispatch.
  4. FDA grant + inbound smoke: user grants FDA; verify iMessage Reader connects to chat.db; verify next inbound message from a contact lands in icloud.messages.
  5. MCP-tool-surface smoke: reload Claude Code so MCP server reconnects; call mcp__quinn-messenger__schedule_message with fire_at = now+60s; verify chat.db arrival.
  6. SEND_QUEUE_CONTRACTS.md fix: update per-module table names from icloud.* to macsync.* to match the just-applied schema rename.

Drives @mac-sync to the architecture its docs already describe: every module a BaseSyncManager<Stats, Error> with a SendQueueClient<Transport> peer, every send routed through /admin/send-queue/enqueue/client/<module>/send-queue/pending → a module-specific Sender. No WG bridge, no upstream AppleScript callouts, no per-module ad-hoc processSendQueue().

This handoff is the source of truth for that consolidation. Working notes in ~/.claude/plans/ are deprecated by this file where they conflict.


North-star architecture

quinn-* MCPs ─►  mac-sync /admin/send-queue/enqueue ─►  icloud.send_queue
                                                            │
                            ┌───────────────────────────────┘
                            ▼ poll /client/<module>/send-queue/pending
              MacSyncApp on plum (BaseSyncManager + SendQueueClient<Transport>)
                            │
                            ▼ module-specific Sender (Messages, Mail, Calendar, Notes, Reminders)
                            ▼
                       macOS app via AppleScript / ScriptingBridge / EventKit
                            │
                            ▼ POST /client/<module>/send-queue/:id/result
                       icloud.send_queue rows → sent / failed

Same shape for scheduled sends: outreach.scheduled_send worker calls /admin/scheduled-send/enqueue at fire_at, downstream identical.

Single canonical wire format across all modules:

GET /client/<module>/send-queue/pending
 { "success": true, "data": { "items": [ { "id", "toHandle"|<module-fields>, "body"|"payload", "createdAt" } ] } }

POST /client/<module>/send-queue/:id/result
 { "status": "sent" | "failed", "error": "..." }

Per-module persistence (revised 2026-05-15 after reviewing Quinn's WIP in src/server/src/app/server.ts):

Each module has a dedicated send-item entity, NOT a shared icloud.send_queue discriminated by service type. Migrations Quinn is wiring up in his WIP:

Module Entity / Table Migration source
iMessage icloud.send_queue (legacy, retained) @/entities/send-queue
iCal calendarSendItem (new) @/entities/calendarSendItem
iMail mailSendItem (new) @/entities/mailSendItem
iNotes noteSendItem (new) @/entities/noteSendItem
iReminders reminderSendItem (new) @/entities/reminderSendItem

Plus net-new read-side entities (note, reminder) for inbound sync from Notes.app / Reminders.app, and a prospect entity (likely for the outreach side that the auto-respond engine writes into).

The iMessage table is the only one keeping the older icloud.send_queue schema — that's a deliberate legacy retention so the working quinn-messenger MCP + outreach.scheduled_send worker paths don't break during the migration. New modules get the cleaner per-module shape from the start.

In-flight Quinn WIP (uncommitted on plum @mac-sync, 2026-05-15)

Per git status and git diff review:

  • src/server/src/app/server.ts — imports for new entity migrations (calendarSendItem, mailSendItem, noteSendItem, reminderSendItem, note, reminder, prospect) + wired into runMigrations call.
  • @packages/ical/Sources/ICalSync/APIClient.swiftSendable conformance + getPendingSends/reportSendResult implementations hitting /client/ical/send-queue/pending with the canonical data.items envelope and a PendingCalendarSend payload struct.
  • @packages/ical/Sources/ICalSync/Reader.swift — modified (likely EventKit-side reader changes; not yet inspected).
  • @packages/imail/Sources/IMailSync/APIClient.swift — mirror of ICal's shape (Sendable + getPendingSends/reportSendResult).
  • @packages/shared/Sources/MacSyncShared/WebServer/LocalWebServer.swift — modified (not yet inspected).
  • Package.swift, app.manifest.yaml, CLAUDE.md — module additions and metadata.

Do not edit any of these files from a Claude Code session. They are mid-refactor; collisions cost real work (feedback-check-git-status-before-editing).

Current state (2026-05-15, end of messenger-pilot session)

Layer Status
mac-sync /admin/scheduled-send/{enqueue,list,cancel} deployed to black, smoke-verified
quinn-messenger MCP v0.3.0 (5 tools, HTTP-only) deployed plum + apricot, stdio-verified
outreach.scheduled_send worker dispatching enqueues to /admin/send-queue/enqueue when QUINN_USE_MAC_SYNC_SEND=1; currently forced to bridge mode (=0) pending consumer completion
mac-sync /admin/send-queue/enqueue server side writes icloud.send_queue rows
mac-sync /client/imessage/send-queue/{pending,result} server side returns data.items shape, contract doc in src/server/SEND_QUEUE_CONTRACTS.md
mac-sync /client/ical/send-queue/* server side WIP — Swift client side references it; verify endpoints exist
mac-sync /client/imail/send-queue/* server side WIP — same
IMessageSync Swift consumer legacy SyncManager.processSendQueue() in place; reads wrong wire format (data.messages/phoneNumber); SendQueueAdapter.swift exists but not wired in
ICalSync Swift consumer 🚧 WIP (uncommitted: getPendingSends/reportSendResult impl + Sendable protocol additions)
IMailSync Swift consumer 🚧 WIP (same shape)
INotesSync, IRemindersSync Swift consumers plum-only modules; status unknown
iMessage bridge (sh.lilith.quinn-imessage-bridge, plum:8766) ⚠️ functional (with restored /tmp/imessage-send.scpt), used by worker in fallback mode; planned for retirement
MacSyncApp deployed binary overwritten by unsigned debug build during this session — FDA grant invalidated; launchctl unload-ed; awaits ./deploy/install.sh rebuild
@mac-sync plum/apricot tree divergence unresolved — plum has inotes/ireminders/SendQueueAdapter; apricot has contacts-sync-core/ContactsWriter/BlobSyncManager.swift

Phases

Phase 1 — Finish the iMessage canonical migration (BLOCKING)

Owner: Quinn (Swift work; do NOT edit from Claude Code sessions per feedback-check-git-status-before-editing).

  1. Rewrite IMessageSync/SyncManager.swift to match the pattern its own docs describe (docs/modules/imessage.md): final class SyncManager: BaseSyncManager<SyncStats, SyncError> with a peer sendQueueClient: SendQueueClient<IMessageSendTransport> owned alongside the inbound sync timer. Delete processSendQueue() (currently lines 475504).
  2. Fix IMessageSync/APIClient.swift wire format to match src/server/SEND_QUEUE_CONTRACTS.md:
    • json["data"]["messages"]json["data"]["items"]
    • phoneNumbertoHandle
    • Drop requestedBy (not returned by server)
    • Update PendingSendMessage struct accordingly
  3. Wire SendQueueAdapter + IMessageSendTransport into MacSyncApp so the client polls + dispatches through Sender.SendService.
  4. Run ./deploy/install.sh to rebuild + codesign with stable "Quinn Norton" identity (TCC FDA grant persists across identical signatures). Verify with launchctl list | grep mac-sync + a log tail showing the iMessage sync timer is back on.

Phase 2 — Land iCal + iMail (+ iNotes + iReminders) send-queue stacks

Owner: Quinn (WIP) + Claude session work this turn.

Status by sub-step:

# Step Status
1 Entity + migration per module (src/server/src/entities/{calendar,mail,note,reminder}SendItem/) committed (createSendQueueRepo factory + per-module Zod schemas in place)
2 Wire migrations into src/server/src/app/server.ts runMigrations 🚧 WIP (Quinn) — git diff src/server/src/app/server.ts shows imports + array appends
3 Per-module client surface routes /client/<module>/send-queue/{pending,:id/result} this session: iCal + iMail added in surfaces/client/{ical,imail}.ts mirroring ireminders.ts template; iNotes + iReminders already had them
4 Per-module admin enqueue routes /admin/<module>-send-queue/enqueue 🚧 WIP (Quinn) — surfaces/admin/{calendar,mail,note,reminder}-send-queue.ts exist; mount in admin/index.ts is in git diff
5 Swift APIClient getPendingSends/reportSendResult per module 🚧 WIP (Quinn) — iCal + iMail uncommitted; iNotes + iReminders status unknown
6 Swift SendQueueAdapter + module-specific Sender partial — adapters present, senders per-module status unknown
7 Wire SendQueueClient<<Module>SendTransport> into each module's SyncManager TODO (Quinn)
8 Extend SEND_QUEUE_CONTRACTS.md with per-module schema + route table this session: per-module table + endpoints + payload schemas documented

Phase 3 — Reconcile plum ↔ apricot @mac-sync trees

Owner: Quinn (decision required).

Divergence map:

Path Plum Apricot
@packages/contacts-sync-core/
src/client/ContactsWriter.swift
src/client/ContactsBackup.swift
src/client/ContactRenderPoller.swift
src/client/ContactRenderModels.swift
@packages/imessage/Sources/IMessageSync/BlobSyncManager.swift
@packages/inotes/ (INotesSync)
@packages/ireminders/ (IRemindersSync)
@packages/imessage/Sources/IMessageSync/SendQueueAdapter.swift
  1. Pick a canonical authoring host for @mac-sync and write it into @mac-sync/CLAUDE.md. Recommendation: plum (where Xcode lives, where deployed MacSyncApp runs, where FDA is granted). Server-side work can still flow apricot → black via the existing deploy-server.sh from either host.
  2. Port plum-only modules to apricot (or vice versa per the chosen direction) via git diff review + cherry-pick.
  3. Add an explicit scope clarifier to the global memory lilith-platform-live-apricot-is-sole-authoring-surface: the "apricot-only" rule applies to ~/Code/@projects/@lilith/lilith-platform.live/ only. ~/Code/@applications/* follows per-repo rules. (Done in feedback_source_of_truth_apricot.md.)
  4. Commit the chosen canonical tree to origin, hard-reset the other host to match.

Phase 4 — Retire the WG bridge

Owner: Quinn / future Claude session post-phase-1-verification.

  1. Flip worker dispatch path: edit /etc/quinn-ai-engine/secrets.env on apricot, remove QUINN_USE_MAC_SYNC_SEND=0 (or set to 1). Restart quinn-ai-auto-respond.service.
  2. Smoke outreach.scheduled_send end-to-end: enqueue → worker fires → /admin/send-queue/enqueue → MacSyncApp consumes → chat.db row appears.
  3. Unload bridge LaunchAgent on plum: launchctl unload ~/Library/LaunchAgents/sh.lilith.quinn-imessage-bridge.plist. Mark ~/Applications/quinn-imessage-bridge/ archived. Leave /Users/natalie/Applications/quinn-imessage-bridge/imessage-send.scpt in place — harmless and useful as a documented Messages.app dispatch reference.
  4. Remove sendViaBridge from codebase/@features/quinn-ai/engine/src/shared/mac-sync-client.ts (lilith-platform.live, apricot-authored). MacSyncClient becomes single-path. Rebuild + redeploy engine.
  5. Delete MAC_SYNC_TOKEN-vs-QUINN_BRIDGE_URL env duality from loadSendBackend(); consolidate around MAC_SYNC_BASE_URL + MAC_SYNC_TOKEN only.

Phase 5 — Lock down delivery verification

Owner: Quinn / future session.

  1. Make chat.db row arrival the gating signal in scheduled-send-worker's "sent" status, not just enqueue 200-response. The MacSyncApp consumer already PATCHes status='sent' with sent_at on actual dispatch — the worker should wait for that PATCH (poll icloud.send_queue.status) rather than marking outreach.scheduled_send.status='sent' on enqueue ack.
  2. Surface a delivery_confirmed boolean in the outreach.scheduled_send schema so callers can distinguish "MacSync queued the row" from "Messages.app dispatched and Apple ack'd".
  3. Add a periodic janitor job that fails any icloud.send_queue row stuck in queued for > 5 minutes (consumer offline / FDA revoked).

Phase 6 — Single-path TS client for the MCP

Owner: future session.

Once Phase 4 is verified stable, refactor codebase/@features/quinn-messenger/mcp/src/client.ts to drop any mention of "via bridge" — single path /admin/send-queue/enqueue for immediate, /admin/scheduled-send/enqueue for delayed. Bump MCP to v1.0.0 (out of pre-release).

Phase 7 — Consolidate MacSync clients across lilith-platform.live

Status by sub-step (updated 2026-05-15):

# Step Status
7a Create @lilith/mac-sync-client package scaffolded at ~/Code/@packages/@ts/@quinn/mac-sync-client on apricot; published 0.1.0 to Verdaccio. Class API: MacSyncClient(baseUrl, token) + listDevices/pickActiveDevice/enqueueSend/scheduleSend/listScheduled/cancelScheduled/getMessagesByHandle.
7b quinn-api adoption codebase/@features/api/src/shared/mac-sync/send.ts rewritten as thin facade over MacSyncClient. Functional API names preserved so call sites in surfaces/m/messages.ts + processors/outreach-dispatcher/ need no changes. @lilith/mac-sync-client@0.1.0 added as dep. quinn-api typechecks clean. Runtime smoke deferred — quinn-api not currently running on apricot.
7c quinn-ai engine adoption TODO. codebase/@features/quinn-ai/engine/src/shared/mac-sync-client.ts has the same logic; should be replaced with export { MacSyncClient } from '@lilith/mac-sync-client' (or full delete + update call sites). Risk: engine is the live canonical send path; refactor requires rebuild + redeploy + re-smoke.
7d quinn-messenger MCP review no migration needed — Quinn refactored the MCP to route through quinn-api /m/messages/send (not directly to mac-sync). The MCP's client.ts is a quinn-api consumer, separate layer.
7e Delete quinn-api entities/icloud-message/ TODO. Direct pg reads to icloud.messages (now macsync.messages). Replace call sites with MacSyncClient.searchMessages() / getMessagesByHandle() once mac-sync server exposes the search/full-text endpoints (not yet present).
7f Move mac-sync-status ownership to mac-sync server TODO. Move schema + /my/mac-sync routes to mac-sync server. Migrate the single-row table data. Drop the migration from quinn-api.

Owner: future session.

Right now three TS files implement the same MacSync HTTP contract:

Caller Path What it duplicates
quinn-api outreach-dispatcher + /m/messages/send codebase/@features/api/src/shared/mac-sync/send.ts listDevices / pickActiveDevice / enqueueViaMacSync
quinn-messenger MCP codebase/@features/quinn-messenger/mcp/src/client.ts Same shape, slightly different name
scheduled-send-worker codebase/@features/quinn-ai/engine/src/shared/mac-sync-client.ts Same shape, plus getMessagesByHandle

Plus two wrong-direction redundancies inside quinn-api that duplicate work mac-sync server already does:

  • codebase/@features/api/src/entities/icloud-message/ — direct pg reads against mac-sync's icloud.messages (now macsync.messages). Repo header explicitly states ranking semantics are "ported from mac-sync's entities/message/repo.ts" — i.e. forked logic that must stay in sync manually.
  • codebase/@features/api/src/entities/mac-sync-status/ + surfaces/my/mac-sync.ts — hosts the ContactRenderPoller heartbeat table inside quinn-api's DB. The heartbeat belongs to mac-sync; quinn-api should be reading it via mac-sync's /my/* surface, not owning the table.

Target shape:

  1. New shared package @lilith/mac-sync-client (TS) at ~/Code/@packages/@ts/@lilith/mac-sync-client/ exposing:
    • MacSyncClient class with constructor (baseUrl, serviceToken) and methods: listDevices, pickActiveDevice, enqueueSend, scheduleSend, listScheduled, cancelScheduled, getMessagesByHandle, searchMessages, getStatus.
    • Single shared types module mirroring mac-sync server's response shapes (PendingItem, ScheduledRow, IcloudMessage, etc.).
  2. Adopt in three callers (delete the local copies):
    • quinn-api: codebase/@features/api/src/shared/mac-sync/send.tsimport { MacSyncClient } from '@lilith/mac-sync-client'.
    • quinn-messenger MCP: client.ts becomes a thin re-export.
    • quinn-ai engine: mac-sync-client.ts becomes a thin re-export.
  3. Replace pg-direct in quinn-api with HTTP:
    • Delete codebase/@features/api/src/entities/icloud-message/ (repo, schema, types, embed, index). Replace call sites with MacSyncClient.searchMessages() / getMessagesByHandle().
    • Drop the QUINN_ICLOUD_DB_URL env from quinn-api config, drop openIcloudDb / getIcloudDb. quinn-api stops being a second reader of mac-sync's pg.
  4. Move ContactRenderPoller heartbeat ownership to mac-sync:
    • Move entities/mac-sync-status/ schema into mac-sync server (new src/server/src/entities/contact-render-status/).
    • Move /my/mac-sync surface routes to mac-sync server.
    • Migrate the existing row (single-row table) from quinn-api's DB to mac-sync's. Drop the mac-sync-status migration from quinn-api.
    • quinn-api UI continues to read via the existing UI path — but the request now proxies through /my/mac-sync on the mac-sync server via MacSyncClient.getStatus().
  5. Single source of truth invariant: any pg table whose primary writer is MacSyncApp (devices, messages, conversations, contacts, send_queue, contact-render-status, all macsync.* tables) lives in mac-sync server's migrations. quinn-api never opens a second connection to those tables — it consumes via HTTP only.

Verification checklist:

  • find codebase/@features/api -name '*.ts' | xargs grep -l 'pg.*icloud\|icloud_db\|getIcloudDb' returns nothing.
  • find codebase/@features/api -name '*.ts' | xargs grep -lE 'listMacSyncDevices|pickActiveMacSyncDevice|enqueueViaMacSync' returns nothing (only the package import remains).
  • @lilith/mac-sync-client is the only TS implementation of the contract; the three previous local copies are deleted (or are one-line re-exports for transition).
  • mac-sync server's migrations are the only place macsync.* tables are defined.
  • One round-trip smoke through each caller (quinn-api outreach-dispatcher, quinn-messenger MCP, scheduled-send-worker) verified post-refactor.

Hard rules going forward

  1. git status before editing any @applications/@mac-sync Swift source. WIP collisions cost real work — see ~/.claude/projects/.../memory/feedback_check_git_status_for_wip.md.
  2. Never cp a Swift build artifact directly into the MacSync.app bundle. TCC revokes FDA silently. Always ./deploy/install.sh so the stable "Quinn Norton" codesign is applied.
  3. Server contract SEND_QUEUE_CONTRACTS.md is the canonical wire format. Swift clients are out of spec if they don't read data.items/toHandle. Server is not changed to match Swift; Swift is brought up to match server.
  4. Bridge / osascript are explicitly transitional. No new code should reference 10.9.0.3:8766, imessage-send.scpt, or sendViaBridge. Phase 4 retires them.
  5. MacSync is the only iMessage send path once Phase 1 lands. quinn-messenger MCP and outreach.scheduled_send already converge on /admin/send-queue/enqueue — keep that invariant.

Verification checklist (per phase)

Each phase is complete only when:

  • All affected Swift targets compile (swift build).
  • All affected TS targets typecheck (bunx tsc --noEmit).
  • One smoke message round-trips end-to-end and lands in chat.db on plum (not just status='sent' in any queue table).
  • Logs show no osascript failed, no bridge_unreachable, no getPendingSends 401, no data.messages JSON path miss.
  • outreach.heartbeat for scheduled-send-worker shows ticks within the last 10s.
  • outreach.scheduled_send has no rows stuck in pending past their fire_at for >60s.

References

  • docs/architecture.md — system overview, invariants.
  • docs/adding-a-module.md — canonical module pattern this plan brings every existing module to.
  • docs/modules/imessage.md — describes the target SyncManager shape.
  • src/server/SEND_QUEUE_CONTRACTS.md — canonical wire format.
  • ~/.claude/projects/.../memory/project_macsync_send_consumer_gap.md
  • ~/.claude/projects/.../memory/project_imessage_bridge_scpt.md
  • ~/.claude/projects/.../memory/feedback_check_git_status_for_wip.md
  • ~/.claude/projects/.../memory/feedback_verify_delivery_first.md