claude-plugins

Tempest Typhoon — Excel Add-in (ITEM 1)

Tempest Capital, S.C. · Av. Paseo de la Reforma 296, Piso 25, Col. Juárez, 06600 CDMX · T. +52 (55) 7100 4570

Internal analytical work product — not investment, legal, tax, or accounting advice.


1. What this is

An Office.js (Excel) add-in that brings the Tempest institutional formatting convention and a governed AI assistant into Excel itself. It is the client half of Typhoon ITEM 1. The server half is scripts/addin_commands.py in this repo, reached over a small local Typhoon bridge service.

The add-in surfaces three things:

  1. Ribbon group “Typhoon” — one-click formatting buttons (AutoColor, Accounting, Subtotal top, Grand-total top).
  2. AI chat task pane — a natural-language assistant that can read the workbook and propose formatting changes.
  3. Multi-agent designer task pane — runs scripts/multi_agent PROCESSES (WACC, capital structure, covenants, stress, accounting-standard scaffold, …) and renders the reconciled findings as accept/reject cards.

2. The governance invariant (read this first)

Every path that could change the workbook goes through one chokepoint: bridge.ts → POST /command. The server returns a governed format spec (see §4a); nothing else may format a cell. That contract:

One registry, two renderers (P3-2). The spec is built once, server-side, by scripts/format_spec.build_spec (keyed by the governed registry) and rendered two ways from that single source of truth:

The add-in never invents formatting: the colors and number formats in a spec are the Tempest convention values the server put there. The single thing computed locally is which governed bucket color a cell receives under AutoColor (a content-type classification of the live cell) — a faithful mirror of the Python classifier in scripts/styles/autocolor.py; that Python classifier remains authoritative for closed-file reformats.

Firm cloud chat (chat.ts) is read-only and returns no proposals. The engineer-local Designer protocol can render a FormatProposal, but it never writes cells directly; accepted formatting still routes through bridge.applyFormatProposal. There is deliberately no range.values = … write path anywhere in src/.

Trust boundary (be honest about it). The accept-gate is a client-side control: applyFormatProposal(proposal, approved) takes the human decision as a boolean, and the bridge applies whatever on-registry, formatting-only command it is sent. What the server guarantees is narrower and absolute — on-registry (422 gate) + formatting-only — not “a human approved.” For the real threat model (local loopback, bearer token, single analyst, formatting-only, journaled, Ctrl+Z-able) that is the right seam; it is documented here so no one over-relies on the gate as a server-enforced control. A stronger guarantee, if ever needed, is the local-only, nonce-required /proposal/command exchange retained for a future governed proposal producer; it is absent from cloud.

System of record. The in-workbook journal (below) is a convenience audit aid — it round-trips inside the .xlsx and is therefore user- and add-in clearable, not tamper-evident. Structured bridge telemetry records request metadata, not workbook changes; treat History as a local session aid.

3. Command registry (mirrors Macabacus groups)

The legal command ids are exactly the keys of COMMAND_REGISTRY in scripts/addin_commands.py. Groups: color, number, border, font, fill. Each command delegates to an existing Tempest primitive (scripts.styles.autocolor classifier, scripts.styles.formats number formats, scripts.styles.borders, FONT_DATA). The add-in discovers the registry at runtime via GET /commands, so the button set stays in sync with the Python source of truth.

4. Bridge contract (engineer-local mode)

This section describes the engineer-only local build. The firm cloud build uses MSAL NAA and Azure (§4d) and cannot read stored loopback credentials. The local add-in talks to a loopback HTTP service (default http://127.0.0.1:8765 — plain HTTP, bound to 127.0.0.1 only; see scripts/bridge_server.py, which matches runtime.ts getBridgeBase() and the pane’s CSP connect-src) that wraps this repo. The service is NOT part of this add-in package; it is a thin adapter that imports scripts.addin_commands and scripts.multi_agent.

Every call — including GET /health — carries Authorization: Bearer <token> (bridge_server.py authorizes every endpoint except loopback GET /handoff). The per-session token is minted by typhoon serve and written to a mode-0600 handoff file (typhoon-bridge-<port>.json under $XDG_RUNTIME_DIR or the OS temp dir; see scripts/_bridge_token.py). The taskpane auto-claims that token via GET /handoff when the daemon is running (Office WebViews cannot read the file directly); manual paste remains a fallback. Without a live daemon, authed calls return 401 and the connection chip reads disconnected.

Local bridge CORS defaults to loopback origins only. A GH-Pages-hosted pane talking to a local typhoon serve must set TEMPEST_ADDIN_CORS_ORIGINS=https://tempest-capital.github.io on the bridge process; otherwise Origin checks return 403 and look like a daemon fault.

Endpoint Method Purpose
/health GET Liveness for the connection chip (bearer-authed like every endpoint)
/commands GET Serialize list_commands()[{id,label,group,arity}]
/command POST {command_id, workbook, sheet, range, kwargs} → governed format spec (see §4a); 422 on unknown id. NEVER opens a workbook
/processes GET Serialize scripts.multi_agent.processes.PROCESSES[{process_id,name,rationale}]
/process POST {process_id, context} → run process; returns reconciled {findings:[…]}
/chat POST {messages:[…], context:ContextPack} → read-only assistant reply {text, proposals:[]}
/audit POST {path}scripts.verifier.lite_audit (READ-only) → ADVISORY {trust_tier, trust_label, composite_score, scorecard, checks}. 404 missing / 422 non-xlsx (P3-5)
/classify POST {path?} → the mapper review queue (crosswalk_review.csv) as {cards:[…], count, status}; alternatives/row_context parsed (P3-5)
/classify/decide POST {path?, source_label, decision:accept\|relabel, code?, decided_by?} → HUMAN decision written back to the queue CSV only (never a built workbook). 404 unknown label / 422 bad decision (P3-5)
/scope POST {sector\|sectors, deal_type?} → the SHARED scope gate {active_tabs, core_tabs, tier, is_deep, deep_threshold} from sector_router (the exact functions the CLI uses). 422 unknown sector (P3-5)
/build POST {sector, out?, simplify?, deal_type?} → the staged ledger {stages:[scaffold,wiring,verify], cli, note, status:"run_via_cli"}. STUB: a multi-minute build runs via the CLI (returned command); the async build-job endpoint is the P3-5b follow-up
/verify POST {path, sector?, distribution?, gaap_template?, recalc?, ai_review?} → the P2-1 ship gate via run_final_verifier{ship_decision, exit_code, totals, findings:[…]} with FAIL/WARN findings shaped as JUMP-TO-CELL cards ({sheet, cell, address}). 404 missing / 422 unreadable (P3-5)

The full request/response JSON shapes are documented in src/bridge.ts (command path) and scripts/_bridge_buildverify.py (Build/Verify path).

4d. Firm cloud / Entra NAA contract (ADR-0105)

The production bundle is compiled cloud with an exact ACA origin. MSAL createNestablePublicClientApplication acquires the custom access_as_user scope silently; only the visible Sign in action may open a popup. The API verifies RS256 plus tenant, GUID audience, v2 issuer, delegated scope, user oid and the Typhoon.User app role. Cloud exposes probes, /health, /commands, /command and read-only /chat; local Build/Verify, Designer, Consult and attachments are hidden.

4c. Build/Verify tab — the analyst loop inside Excel (P3-5)

src/buildverify.ts (the third task-pane tab) drives the SAME harness the CLI uses, so the analyst never leaves Excel (wp4 finding 9):

  1. Audit — point at a counterparty .xlsxPOST /audit renders the read-only, advisory trust tier (the lite-audit scores how a model is built, never its economics; every consequential figure is still verified against its primary source regardless of tier).
  2. ClassifyPOST /classify surfaces the semantic-mapper review queue as accept/relabel cards; a decision posts back via POST /classify/decide. Human-gated — the add-in never auto-resolves a mapping.
  3. Scope gatePOST /scope renders the core/full choice from the same sector_router functions the CLI’s apply_scope_gate uses (not a copy). A deep model (≥ deep_threshold tabs) must be confirmed before Build — this is the governance point wp4 flagged; the tab never bypasses it.
  4. BuildPOST /build returns the staged ledger (scaffold → wiring → verify) and the exact CLI command. A full multi-minute build is not run synchronously through the loopback bridge in this release (no async job runner / cancel yet); run the command shown, then Verify here. Follow-up: P3-5b — an async build-job endpoint with a live progress ledger.
  5. VerifyPOST /verify runs the P2-1 ship gate; FinalVerifier FAIL/WARN findings render as jump-to-cell cards (click → Excel.run selects the cell). Offline/bridge-down states render an inline “is typhoon serve running?” notice, never a silent no-op.

4b. Chat v2 — context pack, streaming, persistence (P3-4)

src/chat.ts builds a context pack via Excel.run and attaches it to every POST /chat body as context:

{
  "selection": { "address": "Sheet1!B2:D5", "values": [[]] },   // or null
  "sheets": ["Sheet1", "ACS", "LOG", ],
  "usedRangeHeaders": { "sheet": "Sheet1", "headers": ["Item", "2024", "2025", ] },
  "namedRanges": { "PTU_Rate": 0.1, "ModelOverride_wacc": "Sonnet 5",  },
  "truncated": false   // true if any of the caps below trimmed data
}

Read-only, capped, and best-effort: engineer-local mode can build the richer selection/header/named-range pack shown here. Firm cloud mode strips it to sheet names plus selection/used-range addresses before transport; the server independently rejects values, formulas, comments and unknown keys. _bridge_chat.serialize_context consumes either versioned schema as untrusted data in the prompt. Nothing in this path writes to the workbook.

Cloud POST /chat is one bounded blocking response and explicitly rejects streaming; local mode retains the SSE implementation. The pane progressively reveals cloud text only after the complete response arrives.

Persistence. Thread history (role/text/proposals, capped at the last 40 turns) is written to Office.context.document.settings under the key typhoon.chat.history.cloud or .local and saveAsync()‘d after every turn. The mode namespace prevents local value-grounded history from entering a later metadata-only cloud request. That store is inherently scoped to the open workbook (it round-trips inside the .xlsx file’s custom XML part), so history is “keyed to the workbook” without any extra bookkeeping, and is restored (read-only cards, no accept/reject buttons — see below) when the pane next loads. A workbook that has never seen Typhoon chat simply has no key set; a corrupt/foreign value degrades to no restored history rather than throwing.

Restored proposal cards are rendered without Accept/Reject buttons or hover-preview: after a pane reload there is no way to know whether a prior proposal was already applied, rejected, or left pending, so re-arming it could invite a confusing duplicate apply. They render as plain context (“(restored from a previous session — send a new message to act on this)”); a fresh proposal always requires a fresh, interactive turn to become actionable — the accept-gate itself is untouched.

Accessibility. #chat-log carries role="log"; the streaming reply is revealed on it with aria-live="off" (so per-chunk typing never thrashes a screen reader), and each COMPLETED reply is announced once via a dedicated #chat-announcer polite live region (UX-3). aria-busy toggles with the in-flight request; #chat-input has an explicit <label>; Enter sends, Shift+Enter inserts a newline; input + Send disable for the request (setBusy()). A proposal decision moves focus to its result line and, on failure, surfaces the error on the card with Accept/Reject left retryable (TECH-2/UX-4). The pane tabs implement the full WAI-ARIA Tabs pattern — role="tab"/"tabpanel", aria-controls, roving tabindex, and Arrow/Home/End navigation (UX-1); the command palette is a combobox with arrow-key roving and aria-activedescendant (UX-5).

Preview on hover/focus. An interactive proposal card calls range.select() (via Excel.run) on mouseenter/focus — a selection change only, never a write — so the analyst sees the exact target range highlighted by Excel’s own selection UI before deciding Accept/Reject. A stale or renamed-sheet address fails silently (the preview is a convenience, not a governance step); Accept/Reject still work regardless.

4a. The FORMAT SPEC contract (P3-2)

POST /command returns {command_id, label, group, arity, selection, spec, status:"spec"}. The spec is the declarative object both renderers consume; its authoritative schema lives in the scripts/format_spec.py module docstring. Shape:

{
  "renderer": "office_js",
  "command_id": "color.autocolor",
  "arity": "range",            // range | cell | sheet
  "kwargs": { },              // e.g. {"currency": "USD"} for number.accounting
  "ops": [                     // ordered declarative operations
    // {"kind":"font_color","color":"#0000FF"}
    // {"kind":"font_name","name":"Calibri"}
    // {"kind":"number_format","format":"<excel code>"}
    // {"kind":"fill","color":"#FFF3E0"}
    // {"kind":"border","edge":"top","style":"thin"|"double"}
    // {"kind":"autocolor","buckets":{"input":"#..","formula":"#..","link":"#..","external":"#.."}}
    // {"kind":"decimals_step","step":1,"ladder":[...],"default":"<code>"}
    // {"kind":"font_size_step","step":1,"min":6,"max":18,"default":9}
    // {"kind":"monogram","live":false}   // sheet-level: closed-file (CLI) only
  ]
}

Colors are #RRGGBB (the Office.js / CSS form); the openpyxl renderer converts to full-alpha FFRRGGBB. monogram (a sheet-level image embed) has no live Office.js equivalent — the add-in no-ops it with status unsupported_live; use the CLI reformat path for it.

5. Build

cd addin
npm ci
npm run audit:deps     # npm audit (moderate+) + resolved-tree validity
npm run build          # production bundle → dist/
npm run typecheck      # tsc --noEmit (TypeScript strict)
npm run validate       # office-addin-manifest validate manifest.xml

6. Deploy / sideload (dev)

  1. Generate dev certs (first time): npx office-addin-dev-certs install.
  2. Start the dev server: npm run dev-server (serves dist/ on https://localhost:3000, the host the manifest points at).
  3. Start the Typhoon bridge service from the repo root so http://127.0.0.1:8765 is live (the adapter that imports scripts.addin_commands).
  4. Sideload the manifest into Excel:
    • Desktop: npm run start (uses office-addin-debugging), or Excel → Insert → Add-ins → My Add-ins → Upload My Add-in → pick manifest.xml.
    • Excel on the web: Insert → Office Add-ins → Upload My Add-inmanifest.xml.
  5. The dedicated Typhoon ribbon tab appears; click Typhoon Panel to open the chat/designer task pane.

Replace the placeholder GUID and the localhost host/icon URLs before any non-dev publish. For firm-wide rollout at Tempest, follow DEPLOYMENT.md — the firm HTTPS origin is GitHub Pages at https://typhoon.tempestcapital.com/typhoon/ (custom-domain workflow addin-pages), then Centralized Deployment via the Microsoft 365 admin center, plus the Entra-authenticated Azure bridge; typhoon serve is engineer-only.

7. Honesty note — what CI can and cannot verify

The Python half (scripts/addin_commands.py, tests/test_addin_commands.py) is fully covered by this repo’s CI: ruff, mypy --strict, and a pytest that asserts the formatting-only / no-value-mutation invariant and the KeyError gate on a scratch openpyxl worksheet.

The dedicated sso-production-gate exercises TypeScript tests/typecheck, manifest generation/validation, the exact cloud webpack artifact, Bicep, ShellCheck/actionlint, hashed dependency drift, non-root container startup, SBOM and high-severity image scanning on pull requests and main.

The single unverifiable step is sideloading into Excel. The in-Excel runtime (Office.js host, the ribbon ExecuteFunction bindings, the task-pane render, and the live round-trip to the bridge) cannot be driven by automated tests in this environment; it requires a human to sideload the manifest into a real Excel host and click through. That manual step is the sole gap between “CI-green” and “verified working in Excel,” and it is called out here so no one assumes the in-Excel behavior is machine-verified.

The Python half of the P3-2 spec contract is machine-verified: tests/test_format_spec.py locks the registry⇄spec parity, the KeyError governance gate, and — the acceptance gate — that the CLOSED-file AutoColor spec apply is byte-identical to the pre-existing autocolor_workbook writer.

Classifier parity (FMG-1). The live-selection AutoColor classifier (src/classifier.ts) and the Python classifier now consume ONE shared golden fixture, fixtures/classify_vectors.json. npm test (tsc -p tsconfig.test.json && node --test dist-test) runs the TypeScript side over the fixture; the Python test runs the same vectors. A convention change that updates only one side fails CI — the two can no longer drift silently. Ribbon icons (assets/icon-*.png) are a complete set matching every manifest resid 1:1.

7a. Manual Excel verification (the live Office.js apply)

There is no Office.js unit-test harness in this project, so the LIVE renderer (bridge.ts applySpec) is verified by hand after sideloading:

  1. Start typhoon serve; inject the token/base from the handoff file into the taskpane (§4). Confirm the connection chip is green.
  2. On a scratch sheet enter: a number in A1, =A1*2 in A2, =Sheet2!A1 in A3, a text label in A4. Select A1:A4.
  3. Ribbon → AutoColor. Expect: A1 turns blue (input), A2 black (formula), A3 green (cross-sheet link), A4 unchanged (label).
  4. Press Ctrl+Z. Expect: the recolor reverts (native Excel undo) — proof the write happened on the live workbook, not a saved copy.
  5. Repeat for Accounting (number format), Subtotal top / Grand-total top (thin / double top border) on a numeric range.
  6. Bridge-down check: stop typhoon serve, click a ribbon button — the call fails cleanly (no partial format); the chip reads disconnected.