Skip to content

PAC v0.8 — Second-brain architecture

Status: Shipped Friday 22 May 2026 overnight. 168/168 QA gates green, 86/86 unit tests green (32 ranker + 22 threads + 32 draft). Bundle 396.6 KB JS / 88.5 KB CSS. Predecessor doc: pac-architecture.md (v0.2 → v0.7). Next: swap draft Option C → Option B once workiq mail-write surface is verified.

PAC v0.8 transforms the cockpit from a single-page dashboard into a three-tab second-brain workspace backed by a local store fed via WorkIQ. Atlas-drafted replies land in ~/.copilot/drafts/ only — Rule #2 is absolute.

The shape

flowchart LR
  subgraph PAC["PAC Electron app"]
    direction TB
    Triage["Triage tab<br/>(top-N ranking)"]
    Cal["Calendar tab<br/>(7-day strip)"]
    Threads["Threads tab<br/>(threaded inbox)"]
    Sidebar["AtlasChat sidebar<br/>(ACP / read-only)"]
    Store[("Store<br/>~/AppData/AtlasCockpit/<br/>store/*.json")]
    Bridge["bridge.ts<br/>(spawns workiq)"]
    Ranker["ranker.ts<br/>(pure)"]
    ThreadsLib["threads.ts<br/>(pure, JW-fuzzy)"]
    Prep["prep-state.ts<br/>(note-file scan)"]
    Draft["draft.ts<br/>(spawn fresh ACP)"]
  end

  Workiq["workiq.cmd<br/>(CA-pre-approved)"] --> Bridge
  Bridge --> Store
  Store --> Triage
  Store --> Cal
  Store --> Threads
  Ranker --> Triage
  ThreadsLib --> Threads
  Prep --> Cal
  Cal -->|"pac:atlas-prompt"| Sidebar
  Threads -->|"pac:atlas-prompt"| Sidebar
  Triage -->|"draft:create IPC"| Draft
  Draft --> Drafts[("~/.copilot/drafts/<br/>draft-*.md")]
  Draft -. spawns .-> CLIAtlas[["copilot --acp<br/>(read-only)"]]

Tab key invariant

  • TRIAGE (default) · CAL · THREADS · HOME (v0.7 legacy) · TEAMS · EMAIL · MEETINGS · CUST360 · CONNECT · REPOS · MSX · SCOUT
  • Tab buttons live in Chrome.tsx::TabBar. Renderer body in Home.tsx switches on activeTab.

Architecture Gate #1 — Bridge approach

Option Verdict
HTTP API on Clawpilot ❌ Requires Clawpilot source mods we don't own
Snapshot file written by external scheduler ❌ Stale ≥5 min, who writes?
Clawpilot exposes M365 MCP ❌ Doesn't exist (the Seismic bridge ≠ M365)
PAC spawns workiq.cmd directly PICK

workiq.cmd is CA-pre-approved and already proven from PAC v0.7. v0.8 just extends it with explicit JSON-shaped prompts ("Return the next 14 days of meetings as JSON array of …") and parses the JSON block from the markdown response. Regex-extract fallback if JSON parse fails.

Architecture Gate #2 — Store backend

better-sqlite3 failed to install (no VS Build Tools). Re-evaluated:

Option Verdict
Install VS Build Tools + better-sqlite3 ❌ 30+ min, fragile native binding, Sush asleep
sql.js (WASM SQLite) Workable but ~5× slower writes
sqlite3 (prebuilds) Same risk class
Flat JSON files (atomic temp+rename) PICK — zero deps, mirrors v0.7 userdata.ts pattern, <1K rows = no perf concern

Files at %APPDATA%\AtlasCockpit\store\{events,mail,chats,dismissals,followups}.json. Schema preserved via TypeScript types in preload/store-types.d.ts. Query via Array.filter/sort. Atomic writes via writeFile(tmp) + rename.

Architecture Gate #3 — Draft delivery (Phase 5, the hard one)

m365_create_draft is a Clawpilot host tool, not a CLI Copilot tool. The CLI ACP session PAC spawns has access to read / grep / glob only — no mail-write tools at all. None of Sush's installed CLI plugins (workiq, workiq-productivity, m365-agents-toolkit, msx-mcp, mcp-gateway) verifiably expose a "create Outlook draft" surface either.

# Option Verdict
A ACP Atlas calls m365_create_draft directly ❌ Not in CLI tool surface
B ACP Atlas → workiq-productivity → Outlook Drafts ⚠️ Unverified; risky overnight; v0.9 path
C ACP Atlas returns draft TEXT → PAC writes local .md → Sush copy-pastes PICK — ships overnight, Rule #2 absolute
D ACP + Microsoft Graph + token ❌ Re-introduces auth surface ripped out in v0.2.2

Why C is the honest pick: the voice-and-tone work is the irreplaceable thing. "Land in Outlook Drafts" is a 5-second copy-paste Sush already does for AI replies elsewhere. The draft:create IPC contract is stable — v0.9 swaps implementation to Option B without renderer changes.

Where drafts land: ~/.copilot/drafts/draft-<YYYY-MM-DDTHHMM>-<slug>.md. PAC shell.openPath opens the file immediately so Sush sees it. Rule #2 banner is the first line. File ships with subject + body sections, source metadata, and link back to the originating item.

The store schema (preload/store-types.d.ts is canonical)

EventRow      // calendar event from workiq calendarView
MailRow       // outlook mail from workiq mail
ChatRow       // teams chat from workiq chats (no conversation_id; thread_root only)
DismissalRow  // {item_id, source, dismissed_at}
FollowupRow   // {item_id, source, schedule_for_iso}
Thread        // {key, lastActivity, subject, customer, items: ThreadItem[]}
ThreadItem    // {source, id, ts, …}

The .d.ts is the single source of truth. Never put a .ts + .d.ts version of these types at the same module path. Re-export through preload/index.ts for renderer consumption.

The Triage ranker (pure)

triage/ranker.ts::rank(items, now): TriageItem[] — pure function, 32 unit tests in test-ranker.cjs. No randomness, no LLM, no I/O. Scores on: - recency (mail/chat: newer = higher) - meeting-imminence (event: starts soon = higher; in-progress = highest) - customer + decision-pending classification weights (from classify.ts) - penalty for dismissed/snoozed items

Top-N (currently 5) surface in the Triage tab. Architecture gate fired here too during Phase 2 — Sush questioned whether ranker should sit in main or renderer. Renderer wins: no IPC round-trip per refresh, easy to unit-test, ranker is referentially transparent.

The threading heuristic (pure)

main/threads.ts::buildThreads(events, mail, chats): Thread[] — pure function, 22 unit tests. Group key precedence: 1. cid:<conversation_id> (Graph's conversation thread) 2. tr:<thread_root> (fallback — chats have this, no cid) 3. fuzzy:<customer>|<normalised-subject> (Jaro-Winkler ≥0.7 + same customer + subject ≥6 chars after normalising Re: / Fw: / Fwd: / Aw: / Tr: / Sv: / Wg: / Res: prefixes)

Subjects normalise 3× to chain-strip prefixes (Fwd: Re: hellohello). Prefix-match bonus capped at 4 chars (favours "BNZ migration plan" matching "BNZ migration plan v2" without over-merging "BNZ migration" vs "BNZ marketing").

Prep-state lookup (cheap, file-only)

main/prep-state.ts::lookupPrep(meetings) — scans ~/Documents/notes/ + ~/.copilot/meeting-notes/ for .md/.markdown/.txt/.note files. Token-overlap scoring on filename vs subject + customer (lowercased, >3-char words, minus STOP_WORDS). Sort by score desc then mtime desc. Reads dirs without crashing when absent. No body reads, no LLM calls — cheap to call on every Calendar refresh.

Drafts in Sush's voice

main/draft.ts::createDraft(req) spawns a fresh copilot --acp session (separate from the AtlasChat sidebar) with read-only allow-list. Prompt explicitly: - demands Atlas read voice-and-tone.md first as the only voice authority - holds 5 phrasing fingerprints in mind ("honest take?", "for people like us", short sentences, brag-allergy ON, plain English) - bans the marketing word list (robust / leverage / synergy / delighted) - requires [check this] placeholders for unknown facts - demands strict SUBJECT: / BODY: output format for PAC parsing

PAC parses the response, writes the .md file, opens it in the default editor. Renderer toasts ✓ Draft saved → <path>. Review, then paste into Outlook.

QA suite (test-pac-qa.cjs)

168 gates today (up from 140 in v0.7). The 28 v0.8-specific gates cover every load-bearing artifact:

  • bridge.ts + store.ts presence + atomic-write pattern
  • store-types.d.ts type surface (EventRow / MailRow / ChatRow / DismissalRow / FollowupRow / Thread / ThreadItem)
  • ranker / triage / triage-card presence + button labels
  • prep-state STOP_WORDS guard + readdir pattern
  • threads Jaro-Winkler + normaliseSubject pattern
  • Calendar BRIEF dispatch + Threads filter chips
  • AtlasChat pac:atlas-prompt listener
  • Home.tsx tab routing (TRIAGE / CAL / THREADS)
  • main IPC handlers (bridge:refresh / store:* / prep:lookup / threads:list / draft:create)
  • preload surfaces (store / bridge / prep / threads / draft)
  • draft.ts spawn pattern + voice-and-tone reference + Rule #2 doc-comment + .copilot/drafts target
  • Rule #2 banned-import gate — no m365_send_email / m365_send_email_with_file / m365_forward_email ANYWHERE in src; if m365_reply_to_email ever lands, saveAsDraft:false is forbidden
  • Triage toast must not lie about "Outlook Drafts" — Architecture Gate #3 writes to local .md, not Outlook
  • Test-suite runners (test-draft.cjs / test-threads.cjs / test-ranker.cjs)
  • Banned mock strings (BNZ migration kickoff / Westpac strategy sync / ASB workshop / placeholder@example.com etc.)
  • package.json version ≥ 0.8.0

Growing-guardrail rule (now also applies to PAC): every production bug found in PAC becomes a new check here BEFORE the fix ships. The suite only grows.

v0.9 roadmap

  1. Verify workiq mail-write surface → swap draft.ts to Option B. Drafts land in real Outlook Drafts. Same IPC contract, renderer unchanged.
  2. Auto-refresh timer. Currently manual-only. Probably 15-min interval, configurable, off-while-rate-limited.
  3. First-light QA pass. Click ~ REFRESH with real BNZ/Westpac data and capture screenshots with real signals.
  4. CSS budget breach guard. 88.5 KB / 100 KB is uncomfortably close. Audit unused CSS or bump ceiling to 120.
  5. Polish Triage dismiss + snooze flow. Replace window.prompt('Snooze for how many hours?') with a small modal.
  6. Cmd-K palette extension for direct tab navigation.

Files touched (load-bearing — keep types in sync)

  • src/main/{bridge,store,prep-state,threads,draft,classify,customer-domains,workiq,index}.ts
  • src/preload/{index.ts,store-types.d.ts}
  • src/renderer/src/{triage/*,calendar/*,threads/*,sidebar/AtlasChat.tsx,home/Home.tsx,home/components/Chrome.tsx}
  • test-{ranker,threads,draft,classify,pac-qa}.cjs

Lessons from the overnight build (for the next handoff)

  • Always ship the local-file safety net first. Phase 5's "real Outlook draft" path looked simple from the handoff doc but turned into a 30-min Architecture Gate. Option C buys us a working voice draft today + zero send-risk + a clean swap path for v0.9.
  • Growing guardrails work. The "Triage toast must not lie about Outlook Drafts" check came from a literal setDraftToast('… → check Outlook Drafts') string left over from a hopeful v0.8 plan. Caught on the very next QA run after architecture changed. Cost: 4 lines of regex.
  • PowerShell Move-Item from a non-existent source silently exits 0 and creates an empty destination file. Bit Phase 3 once (calendar.css). When the create tool refuses an "existing" file you didn't intentionally create, Remove-Item it and create fresh.
  • Awaiting store readers. getMail()/getChats()/getEvents() are Promise<Row[]>. TS catches misuse as "missing length/pop/push" errors when treated as raw arrays.