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 inHome.tsxswitches onactiveTab.
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: hello → hello). 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-promptlistener - 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_emailANYWHERE in src; ifm365_reply_to_emailever lands,saveAsDraft:falseis 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¶
- Verify workiq mail-write surface → swap draft.ts to Option B. Drafts land in real Outlook Drafts. Same IPC contract, renderer unchanged.
- Auto-refresh timer. Currently manual-only. Probably 15-min interval, configurable, off-while-rate-limited.
- First-light QA pass. Click ~ REFRESH with real BNZ/Westpac data and capture screenshots with real signals.
- CSS budget breach guard. 88.5 KB / 100 KB is uncomfortably close. Audit unused CSS or bump ceiling to 120.
- Polish Triage dismiss + snooze flow. Replace
window.prompt('Snooze for how many hours?')with a small modal. - 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}.tssrc/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-Itemfrom 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-Itemit and create fresh. - Awaiting store readers.
getMail()/getChats()/getEvents()arePromise<Row[]>. TS catches misuse as "missing length/pop/push" errors when treated as raw arrays.