---
name: carta-fetch-actuals
model: opus
description: 'Write actuals into an existing Excel budget workbook from Carta MCP — add/interleave Budget/Actual/Variance columns, a tag-view tab, or a vendor-view tab. TRIGGER: pull/fetch/get/retrieve/refresh/sync/add actuals for [firm/ManCo], interleave Budget/Actual/Variance, actuals by department/cost center/tag/vendor, add next month/period column, extend budget through [month], broken down by vendor, vendor summary/spend over time/vendors across [period]. NOT: pacing, "how are we doing", "budget versus actual for [period]", or variance-analysis queries (carta-budget-analysis); new budgets (carta-create-budget); scenarios; consolidating P&L / balance sheet. NOT: pulling a stored ManCo budget from Carta (carta-fetch-budget) — this skill writes actuals into an existing workbook, not the budget itself.'
version: 1.2.0
allowed-tools:
  # Production
  - mcp__claude_ai_Carta__call_tool
  - mcp__claude_ai_Carta__welcome
  - mcp__claude_ai_Carta__set_context
  - mcp__claude_ai_Carta__list_contexts
  # Carta-installer naming (lowercase)
  - mcp__carta_production__call_tool
  - mcp__carta_production__welcome
  - mcp__carta_production__set_context
  - mcp__carta_production__list_contexts
  # Local / legacy fallback
  - mcp__carta__call_tool
  - mcp__carta__welcome
  - mcp__carta__set_context
  - mcp__carta__list_contexts
  - AskUserQuestion
  - Read
  - Write
  - Bash(uv run ${CLAUDE_PLUGIN_ROOT}/scripts/read_workbook.py *)
  - Bash(uv run ${CLAUDE_PLUGIN_ROOT}/scripts/write_workbook.py *)
---

<!-- carta:instrumentation-fallback -->
<IMPORTANT>
`_instrumentation_v2` is anonymous Carta skill-usage telemetry. ALWAYS include it in your Carta MCP tool-call arguments — the plugins' hooks overwrite it when they run, so always sending it is safe. Shape:
`_instrumentation_v2: {"skills": ["<plugin>:<skill>", ...], "model": "<model-id>"}`
`skills` is cumulative for the session: list every Carta skill loaded so far, not only the one driving this call. Namespace each entry `"plugin:skill"` and keep them oldest-first, with the most recently used skill last. List only Carta skills (`carta-cap-table:*`, `carta-crm:*`, `carta-investors:*`) — never skills from non-Carta plugins.
</IMPORTANT>

[PATTERN carta-writing-style v0.0.2]
[PATTERN etiquette v0.0.6]
[PATTERN text v0.0.8]
[PATTERN tables v0.0.12]
[PATTERN carta-watermark v0.0.10]
[PATTERN menus-and-flows v0.0.7]
[PATTERN base v0.1.0]

# Budget actuals

Entry point for updating actuals in an existing budget. Seven references:

- [`references/add-actuals-columns.md`](references/add-actuals-columns.md) — **Layout A**: interleave Budget / Actual / Variance per month on the Budget tab (recommended for active tracking).
- [`references/add-actuals-tab.md`](references/add-actuals-tab.md) — **Layout B**: add a peer `<year> Actuals` tab alongside the Budget tab.
- [`references/refresh-existing.md`](references/refresh-existing.md) — **Layout C**: overwrite stale actuals cells in columns that already exist.
- [`references/add-period.md`](references/add-period.md) — **Layout D**: append the single next month/quarter column.
- [`references/tag-view.md`](references/tag-view.md) — **Layout E**: new tab with actuals sliced by reporting dimension (department, project code, class, etc.) and a three-row period / category / tag header. Only offered when the entity has tagged journal data.
- [`references/vendor-view.md`](references/vendor-view.md) — **Layout F**: new tab with actuals sliced by vendor, with per-vendor subtotals and a 'No vendor' section. Only offered when the entity has vendor-tagged journal data.
- [`references/inline-vendor.md`](references/inline-vendor.md) — **Layout G**: vendor sub-rows added inline to the current actuals tab; accounts become subtotals of their vendor children.
- [`references/vendor-only-view.md`](references/vendor-only-view.md) — **Layout H**: new tab with one row per vendor across a timeline — no GL account sub-rows. The lightweight vendor summary view.
- [`references/get-actuals.md`](references/get-actuals.md) — internal helper, the canonical actuals-query routine.

## UX Rules

Audience is an accountant in Excel. Plain English only. Never surface MCP
identifiers, DWH column names, UUIDs, raw JSON, SQL, or gate labels.
Currency: `$X,XXX` positive, `($X,XXX)` negative, totals bolded.
Differences are absolute. Status: ✅ Match | ⚠ Mismatch ($X diff) | ❌ Missing in Carta | ❌ Missing in Client Doc.

**Closing summary link** is a workbook citation (`<citation:Sheet!Range>`) in
Claude for Excel mode, and a `file://` path in Claude Code / Cowork mode.

**Every numbered choice in this skill — including the closing
next-step menu — MUST be presented via `AskUserQuestion`.** Never
render options as a bare code-fenced markdown list. The
`AskUserQuestion` tool is in `allowed-tools`; use it. Bare-text menus
break the chooser UI in Claude for Excel and force the user to type
the number.

## When to use

- "Refresh the actuals on my budget"
- "Pull the latest actuals into the open budget"
- "Update my budget with March numbers"
- "The actuals are stale — sync them"
- "Add next month's column to the budget"
- "Extend the budget through April"

## DO NOT use this skill for

- **Building a new budget from scratch** — use `carta-create-budget`.
- **Pulling the Carta-stored ManCo budget** — use `carta-fetch-budget`.
- **Pacing / YTD vs budget / variance / "are we on track"** — use `carta-budget-analysis`.
- **What-if scenarios** — use `carta-budget-scenarios`.
- **P&L / income statement requests** — use `carta-consolidating-pnl`.
- **Balance sheet requests** — use `carta-consolidating-balance-sheet`.

---

## Execution discipline

Execute all gates silently. Do not narrate tool calls, intermediate results, or status updates. Only speak at explicit decision points: Gate 0.5 (if runtime is ambiguous), Gate 1 (destination chooser), Gate 2 (layout choice — always ask), Gate 3 (period/parameter gate), Gate 5.5 (memo-based vendor inference — offer only, Layouts F/G/H with a non-empty 'No vendor' bucket), Gate 6 (approval), and Gate 8 (next-step menu).

---

## Entry mode — fresh session vs. chained skill

**Mandatory telemetry beacon — run this before any skip-gate check:** If `<SERVER>` and `<ENTITY_UUID>` are already in context, call `mcp__<SERVER>__set_context(firm_id=<ENTITY_UUID>, _instrumentation={"plugin": "carta-investors", "skills": ["carta-fetch-actuals"]})` as your very first action. Do not skip this even when all data is already available — it records this skill invocation. If either is unknown, it fires in Gate 0 below.

Before Gate 0, check whether these context variables are already set from an earlier budgeting skill call in the same session:

- `<SERVER>` — connected Carta MCP server prefix
- `<ENTITY_NAME>` and `<ENTITY_UUID>` — the resolved entity
- `<RUNTIME>` — `excel-addin` or `local-file`

**If all four are in context:** skip Gates 0 and 0.5 entirely. Call `mcp__<SERVER>__set_context(firm_id=<ENTITY_UUID>, _instrumentation={"plugin": "carta-investors", "skills": ["carta-fetch-actuals"]})` to re-anchor the session scope and record this skill invocation. In Gate 3, pre-fill `<ENTITY_NAME>` and skip asking for it — ask only for the period. Proceed from Gate 1 (destination), then Gate 2 (layout choice), then Gate 3 (period).

**If any is missing** (fresh session or cold invocation): run Gates 0 and 0.5 in order, then continue from Gate 1.

Do not ask "which firm?" or "which runtime?" when those are already established from the skill the user just ran.

---

## Gate 0 — Carta MCP environment + resolve firm

Scan the tools available in the conversation for any matching `mcp__*__welcome`. Extract the **server identifier** — the middle segment between the first and last `__`. Examples: `mcp__carta__welcome` → `carta`, `mcp__claude_ai_Carta__welcome` → `claude_ai_Carta`.

**If none found:** tell the user no Carta MCP is connected and stop.
**If exactly one found:** call `mcp__<SERVER>__welcome(_instrumentation={"plugin": "carta-investors", "skills": ["carta-fetch-actuals"]})` to verify. This is `<SERVER>`.
**If multiple found:** ask the user which to use via `AskUserQuestion`. Default to `carta` (production) if present.
**Don't call any other `mcp__<SERVER>__*` tool before `welcome`** — every other command is gated and will return a reminder.

**Resolve firm:** if user named one → `mcp__<SERVER>__list_contexts(firm_name="<entity>", _instrumentation={"plugin": "carta-investors", "skills": ["carta-fetch-actuals"]})` → disambiguate via `AskUserQuestion` if multiple → `mcp__<SERVER>__set_context(firm_id=<FIRM_UUID>, _instrumentation={"plugin": "carta-investors", "skills": ["carta-fetch-actuals"]})`. Do not use `call_tool` for `list_contexts` or `set_context` — call the granular tools directly with `_instrumentation` as shown.


**DWH param-name traps:** `dwh:execute:query` takes `sql:` not `query:`. `dwh:get:table_schema` takes `table_name:` not `table:`. `format` accepts `"ndjson"` / `"markdown"`, not `"csv"`.

If no firm was named, defer to Gate 3.

---

## Gate 0.5 — Detect runtime

Detect whether this is **Claude for Excel** (workbook is open in the
add-in) or **Claude Code / Cowork** (working with a `.xlsx` file on
disk). See `carta-create-budget/SKILL.md` Gate 0.5 for the heuristic — same
rule applies here.

If unclear, ask the user via `AskUserQuestion`:

> "How are you working with the budget — inside Excel via Claude for
> Excel, or as a local .xlsx file (Claude Code / Cowork)?"

Store `<RUNTIME>` (`excel-addin` or `local-file`) for Gates 1, 4, 7, 8.

---

## Gate 1 — Where to write

Branches by `<RUNTIME>`.

**If `<RUNTIME>` is `excel-addin`:**

**Empty-workbook shortcut**: if the active workbook has one sheet, `maxRows == 0`, no other tabs (typically a fresh `Book1.xlsx`/`Sheet1`), skip the chooser. Announce the rename in one sentence — *"I'll use the empty workbook you have open and rename `Sheet1` to `<TARGET_TAB>`."* — then proceed. The chooser only exists to protect non-empty state; an empty workbook has none. The chooser still applies whenever there is data or more than one tab.

> Where should I write the updates?

- **"Update the open workbook directly — recommended"** (modify in place).
- **"Update the open workbook in a new tab"** (preserves the original).
- **"Create a brand new workbook with the updated data"**.

If user picks "update directly", confirm **which tab** explicitly. If
multiple tabs look like budgets, ask which one.

**If `<RUNTIME>` is `local-file`:**

> Where is the budget file, and where should the updated version land?

- **"Modify the file in place — recommended"** — ask for the path.
- **"Write a new file alongside the original"** — ask for the path; new file gets a `-updated` suffix by default.

If the user gave a path in the original prompt, skip the choice. Store
`<DESTINATION>` (open workbook + tab in add-in mode, or `.xlsx` path +
sheet name in local-file mode).

---

## Gate 2 — Choose the layout (always ask)

Four layouts are valid for putting actuals into a workbook, and the
same prompt can plausibly mean any of them. **Always ask the user**
how the actuals should appear — never assume from the prompt's
phrasing alone.

Use `AskUserQuestion`:

> How should the actuals appear in the workbook?

| # | Option | Reference loaded |
|---|---|---|
| 1 | **Interleave Budget / Actual / Variance columns per month** on the Budget tab ← recommended | [`add-actuals-columns.md`](references/add-actuals-columns.md) |
| 2 | **Add a separate `<year> Actuals` tab** alongside the Budget tab | [`add-actuals-tab.md`](references/add-actuals-tab.md) |
| 3 | **Refresh existing Budget / Actual / Variance cells** (the cells are there, just stale) | [`refresh-existing.md`](references/refresh-existing.md) |
| 4 | **Add only the next single period column** | [`add-period.md`](references/add-period.md) |
| 5 | **Build a tag-view tab — actuals sliced by reporting dimension** (department, project code, class, etc.) | [`tag-view.md`](references/tag-view.md) — only offered when the entity has tagged data; see Gate 2.5 |
| 6 | **Build a vendor-view tab — actuals sliced by vendor, with GL account detail** (per-vendor subtotals and collapsible account rows) | [`vendor-view.md`](references/vendor-view.md) — only offered when the entity has vendor-tagged data; see Gate 2.6 |
| 7 | **Add vendor rows inline to the current actuals tab** — insert vendor sub-rows under each account; accounts become subtotals of their vendor children | [`inline-vendor.md`](references/inline-vendor.md) — only offered when the active tab is already an actuals tab; see Gate 2.7 |
| 8 | **Build a vendor summary tab — one row per vendor across a timeline, no GL detail** | [`vendor-only-view.md`](references/vendor-only-view.md) — only offered when the entity has vendor-tagged data; see Gate 2.8 |

Use the user's prompt only as a *hint* for which option to highlight —
never as authority to skip the question:

| Phrase in the prompt | Hint |
|---|---|
| "interleave", "Budget / Actual / Variance", "variance by month", "add `<year>` actuals" (no other clue) | Option 1 (also the default `← recommended`) |
| "add a tab", "track on its own tab", "separate actuals tab" | Option 2 |
| "refresh", "the actuals are stale", "pull latest", "sync" | Option 3 |
| "add next month", "extend through `<month>`", "next period" | Option 4 |
| "by department", "by tag", "by cost center", "split by", "broken down by", "by reporting tag", "by project code" | Option 5 |
| "by vendor", "broken down by vendor", "vendor view", "which vendors", "vendor breakdown" — **and the active tab is already an actuals tab** | Offer Options 6 and 7 together; 7 ← recommended |
| "by vendor", "broken down by vendor", "vendor view", "which vendors", "vendor breakdown" — **no actuals tab open** | Option 6 |
| "break by vendor on this tab", "add vendors here", "vendor breakdown on this tab", "enrich this tab with vendors" | Option 7 |
| "vendor summary", "vendor spend over time", "vendors across", "which vendors did we pay", "vendor totals", "vendor breakdown without accounts", "just vendors" | Option 8 |

**Option 5 availability**: always show Layout E in the chooser. If the user
picks it and Gate 2.5 finds no tag data on the entity, tell them in one sentence
and fall back to Layout A automatically. Do not pre-filter the chooser — the
entity name needed for the probe is not available until Gate 3.

**Option 6 availability**: always show Layout F in the chooser. If the user
picks it and Gate 2.6 finds no vendor data on the entity, tell them in one sentence
and fall back to Layout A automatically. Do not pre-filter the chooser.

**Option 7 availability**: only show Layout G when the active sheet is already
an actuals tab (its name contains "Actuals" or the user is explicitly on it).
If the user picks it and Gate 2.7 finds no vendor data, tell them in one sentence
and fall back to Option 6 (new vendor-view tab) instead.

**Option 8 availability**: always show Layout H in the chooser. If the user
picks it and Gate 2.8 finds no vendor data on the entity, tell them in one sentence
and fall back to Layout A automatically. Do not pre-filter the chooser.

The user's pick locks the reference to load for the rest of the
workflow. **Immediately call `read_skill` for the chosen layout — do not reconstruct from memory:**

| Layout chosen | Call |
|---|---|
| Option 1 — Interleave columns | `read_skill(file_path="references/add-actuals-columns.md")` |
| Option 2 — Add actuals tab | `read_skill(file_path="references/add-actuals-tab.md")` |
| Option 3 — Refresh existing | `read_skill(file_path="references/refresh-existing.md")` |
| Option 4 — Add single period | `read_skill(file_path="references/add-period.md")` |
| Option 5 — Tag-view tab | `read_skill(file_path="references/tag-view.md")` |
| Option 6 — Vendor view tab | `read_skill(file_path="references/vendor-view.md")` |
| Option 7 — Vendor inline | `read_skill(file_path="references/inline-vendor.md")` |
| Option 8 — Vendor summary tab | `read_skill(file_path="references/vendor-only-view.md")` |

> **Why we always ask:** the same prompt — "add 2026 actuals by month"
> — can mean Option 1, 2, or 3 depending on the user's intent and the
> current state of their workbook. Guessing wrong and rebuilding costs
> the user a corrective prompt. Asking once costs one click. **Choose
> the click.**

---

## Gate 2.5 — Tag-category discovery (Layout E path only)

**Skip this gate entirely unless the user chose Layout E at Gate 2.**

**Silent probe — no user-facing output.** Layout E shows **all firm tag categories side by side** under each period band — no "which dimension?" picker. The probe's job is to discover the firm's tag taxonomy from `REPORTING_TAGS_JSON` (or fall back to the flat `REPORTING_TAGS` column when only that's populated).

### Probe 1 — Detect the JSON-vs-flat path

```
call_tool({"name": "dwh__execute__query", "arguments": {
  "sql": "SELECT
            COUNT_IF(REPORTING_TAGS_JSON IS NOT NULL) AS json_rows,
            COUNT_IF(REPORTING_TAGS IS NOT NULL)      AS flat_rows
          FROM <journal_entries_table>
          WHERE FUND_NAME = '<entity_name>'
            AND EFFECTIVE_DATE >= DATEADD('year', -1, CURRENT_DATE)",
  "format": "markdown",
  "_instrumentation": {"plugin": "carta-investors", "skills": ["carta-fetch-actuals"]}
}})
```

- `json_rows > 0` → **JSON path**. Skip Probe 2 — go directly to Probe 3 (JSON path). Probe 3 returns both category names and cardinality in one query, making a separate category-discovery query redundant.
- `json_rows == 0 AND flat_rows > 0` → **flat path**. Set `<CATEGORIES> = ["Reporting Tag"]` and continue to Probe 3 (cardinality).
- Both zero → no tag data on this entity. Tell the user in one plain-English sentence — *"Your journal data doesn't have any reporting tags, so I'll build a flat actuals view instead."* — and fall back to **Layout A**.

### Probe 3 — Cardinality per category

Run **one** query that returns the value count per category (used to drive the wide vs long decision):

**JSON path:**
```
call_tool({"name": "dwh__execute__query", "arguments": {
  "sql": "SELECT f.key::TEXT AS category, COUNT(DISTINCT f.value::TEXT) AS n_values
          FROM <journal_entries_table>,
               LATERAL FLATTEN(input => REPORTING_TAGS_JSON) f
          WHERE FUND_NAME = '<entity_name>'
            AND REPORTING_TAGS_JSON IS NOT NULL
            AND EFFECTIVE_DATE >= DATEADD('year', -1, CURRENT_DATE)
          GROUP BY 1
          ORDER BY 1",
  "format": "markdown",
  "_instrumentation": {"plugin": "carta-investors", "skills": ["carta-fetch-actuals"]}
}})
```

**Flat path:**
```
call_tool({"name": "dwh__execute__query", "arguments": {
  "sql": "SELECT 'Reporting Tag' AS category, COUNT(DISTINCT REPORTING_TAGS) AS n_values
          FROM <journal_entries_table>
          WHERE FUND_NAME = '<entity_name>'
            AND REPORTING_TAGS IS NOT NULL
            AND EFFECTIVE_DATE >= DATEADD('year', -1, CURRENT_DATE)",
  "format": "markdown",
  "_instrumentation": {"plugin": "carta-investors", "skills": ["carta-fetch-actuals"]}
}})
```

Store `<CATEGORIES>` as the sorted list of distinct `category` values returned, and store `<CARDINALITY>` as the map of `category → n_values`. (Probe 3 returns both in one pass — no separate Probe 2 needed.) Compute the **total column count**:

```
total_columns = sum(n_values for each category) + len(<CATEGORIES>)
```

The `+ len(<CATEGORIES>)` term covers the per-category Total columns. If the run uses Quarter or Month aggregation across multiple period blocks, multiply by the number of period blocks for the layout decision.

### Wide vs long decision

The authoritative thresholds live in [`references/tag-view.md`](references/tag-view.md) §"Cardinality guard" — `≤ 24` wide / `25–36` ask / `> 36` long. Keep that file as the single source of truth; the inline cutoffs below mirror it for runtime convenience and must move together if the table changes.

If `total_columns > 24` (single period) or `total_columns × n_period_blocks > 24` (multi-period), ask via `AskUserQuestion`:

> The tag-view tab would have `<N>` columns across `<C>` categories. With that many, should I build a wide table (one column per tag per category per period) or a long table (one row per tag per account)?

- **Wide — one column per tag** ← recommended for ≤ 36
- **Long — one row per tag per account**

Store `<TAG_LAYOUT>` (`wide` | `long`). Default `wide` for ≤ 24 (no question asked); default `long` for `> 36` (no question asked).

---

## Gate 2.6 — Vendor-data discovery (Layout F path only)

**Skip this gate entirely unless the user chose Layout F at Gate 2.**

**Silent probe — no user-facing output.** Checks whether `VENDOR_NAME` is populated and counts distinct vendors.

```
call_tool({"name": "dwh__execute__query", "arguments": {
  "sql": "SELECT
            COUNT_IF(VENDOR_NAME IS NOT NULL) AS tagged_rows,
            COUNT_IF(VENDOR_NAME IS NULL)     AS untagged_rows,
            COUNT(DISTINCT VENDOR_NAME)       AS distinct_vendors
          FROM <journal_entries_table>
          WHERE FUND_NAME = '<entity_name>'
            AND ACCOUNT_TYPE >= '4000'
            AND EFFECTIVE_DATE >= DATEADD('year', -1, CURRENT_DATE)",
  "format": "markdown",
  "_instrumentation": {"plugin": "carta-investors", "skills": ["carta-fetch-actuals"]}
}})
```

- `tagged_rows > 0` → vendor data exists. Store `<VENDOR_COUNT>` = `distinct_vendors` and `<HAS_UNTAGGED>` = (`untagged_rows > 0`). Continue to Gate 3.
- `tagged_rows == 0` → no vendor data on this entity. Tell the user in one plain-English sentence — *"Your journal data doesn't have any vendor information, so I'll build a flat actuals view instead."* — and fall back to **Layout A**.

---

## Gate 2.7 — Vendor-data check (Layout G path only)

**Skip this gate entirely unless the user chose Layout G at Gate 2.**

**Silent probe — no user-facing output.** Same probe as Gate 2.6 — confirms vendor data exists before rebuilding the tab inline.

---

## Gate 2.8 — Vendor-data discovery (Layout H path only)

**Skip this gate entirely unless the user chose Layout H at Gate 2.**

**Silent probe — no user-facing output.** Same probe as Gate 2.6 — checks whether
`VENDOR_NAME` is populated and counts distinct vendors.

```
call_tool({"name": "dwh__execute__query", "arguments": {
  "sql": "SELECT
            COUNT_IF(VENDOR_NAME IS NOT NULL) AS tagged_rows,
            COUNT_IF(VENDOR_NAME IS NULL)     AS untagged_rows,
            COUNT(DISTINCT VENDOR_NAME)       AS distinct_vendors
          FROM <journal_entries_table>
          WHERE FUND_NAME = '<entity_name>'
            AND ACCOUNT_TYPE >= '4000'
            AND EFFECTIVE_DATE >= DATEADD('year', -1, CURRENT_DATE)",
  "format": "markdown",
  "_instrumentation": {"plugin": "carta-investors", "skills": ["carta-fetch-actuals"]}
}})
```

- `tagged_rows > 0` → vendor data exists. Store `<VENDOR_COUNT>` = `distinct_vendors`
  and `<HAS_UNTAGGED>` = (`untagged_rows > 0`). Continue to Gate 3.
- `tagged_rows == 0` → no vendor data on this entity. Tell the user in one plain-English
  sentence — *"Your journal data doesn't have any vendor information, so I'll build a
  flat actuals view instead."* — and fall back to **Layout A**.

---

## Gate 3 — Batched parameter gate

In one `AskUserQuestion`, confirm every parameter the prompt didn't already specify.

**Entity:** confirm `<ENTITY_NAME>` — the exact `FUND_NAME` value used in DWH queries. If the user named one at Gate 0, pre-fill it and only ask if it's ambiguous.

**Period:** offer smart defaults based on today's date (currently May 2026):

> What period should I pull actuals for?

| # | Label | Date range |
|---|---|---|
| 1 ← recommended | **Full year 2026** | Jan 1 – Dec 31, 2026 |
| 2 | **YTD 2026** (Jan – May) | Jan 1 – May 31, 2026 |
| 3 | **Q2 2026** (Apr – Jun, in progress) | Apr 1 – Jun 30, 2026 |
| 4 | **Full year 2025** | Jan 1 – Dec 31, 2025 |
| 5 | **Custom range** — I'll specify start / end month | — |

Adapt `← recommended` and visible options to context: YTD if the user said
"latest"; full year if they said "2026 actuals"; prior year if they said "2025".
Always compute the current quarter's label dynamically from today's date — do
not hardcode Q2.

If the prompt already specified a period (e.g. "Q1 2026", "full year 2025"),
store it directly and skip the period question.

Store `<PERIOD_START>` (first day, `YYYY-MM-DD`) and `<PERIOD_END>` (last day).

**Match strategy** (Layouts A–D only): `name first then GL code` (default) vs `GL code only`. Omit for Layouts E, F, G, or H (no existing-sheet matching needed — these layouts always write a new tab).

Store `<ENTITY_NAME>`, `<PERIOD_START>`, `<PERIOD_END>`, `<MATCH_STRATEGY>` (Layouts A–D).

### Gate 3a — Aggregation level (Layouts E, F, and H only)

**Skip this sub-gate for Layouts A–D and G** — aggregation is always monthly there. Set `<AGGREGATION> = MONTH` and continue.

**For Layouts E, F, and H, this MUST be a separate `AskUserQuestion` call** — do not bundle with the period question above, and do not infer the answer from the period range. A YTD period (e.g. Jan–May) does not mean "Quarter"; a full-year period does not mean "Year". The user picks the period independently from the aggregation.

> Aggregate columns by:

| # | Label |
|---|---|
| 1 ← recommended | **Year** — one period block per year |
| 2 | **Quarter** — one block per quarter |
| 3 | **Month** — one block per month |

Store `<AGGREGATION>` (`YEAR` | `QUARTER` | `MONTH`).

**Hard rule:** if you find yourself building a tag-view or vendor-view with multiple period blocks (e.g. Q1+Q2) without an `AskUserQuestion` whose answer literally selected one of the three options above, you skipped this gate. Stop and ask before proceeding to Gate 4.

### Gate 3b — Vendor row grouping preference (Layouts F and G + excel-addin only)

**Skip this sub-gate for Layouts A–E and H**, or when `<RUNTIME>` is `local-file` (row outline grouping is not supported in the local-file path). Layout H has no GL sub-rows to collapse.

Ask via `AskUserQuestion` — a separate call from Gate 3a above:

> Should vendor detail rows be collapsible in Excel?

| # | Label | Description |
|---|---|---|
| 1 ← recommended | **Yes — collapsed by default** | Rows hidden on open; click **+** to expand a vendor section |
| 2 | **Yes — expanded by default** | Rows visible; click **−** to collapse a vendor section |
| 3 | **No grouping** | Flat tab, no outline controls |

Store `<VENDOR_GROUPING>` (`collapsed` | `expanded` | `none`).

---

## Gate 4 — Read the existing budget

**If `<RUNTIME>` is `excel-addin`:**

Use the Excel add-in's runtime read tools to inspect the budget tab —
header row, line-item rows, actuals/budget columns, formula rows.

**If `<RUNTIME>` is `local-file`:**

```bash
uv run "${CLAUDE_PLUGIN_ROOT}/scripts/read_workbook.py" \
  "<DESTINATION_PATH>" --sheet "<BUDGET_SHEET>"
```

Parse the resulting JSON to identify the same structure (headers,
line items, formula rows). Treat any cell where `is_formula: true` as
load-bearing — never overwrite it.

---

## Gate 5 — Load actuals

**Layout E:** use the category-grouped query from
[`references/tag-view.md`](references/tag-view.md) §SQL. Pick the **JSON path**
when Gate 2.5 detected `REPORTING_TAGS_JSON` rows; the **flat path** when only
`REPORTING_TAGS` was populated. Substitute `<period_trunc>` from `<AGGREGATION>`,
`<period_start>` from `<PERIOD_START>`, `<period_end>` from `<PERIOD_END>`, and
`<entity_name>` from `<ENTITY_NAME>`. The JSON path discovers `<CATEGORIES>`
inside the query via `LATERAL FLATTEN` — do not parameterize the category list
into the SQL. All other rules below apply unchanged.

**Layout F:** use the vendor query from
[`references/vendor-view.md`](references/vendor-view.md) §SQL. Substitute `<period_trunc>`
from `<AGGREGATION>`, `<period_start>` from `<PERIOD_START>`, `<period_end>` from
`<PERIOD_END>`, and `<entity_name>` from `<ENTITY_NAME>`. The query uses
`COALESCE(VENDOR_NAME, 'No vendor')` so NULL-vendor entries roll into a single
'No vendor' section — do not run a second query for untagged rows. All other hard
rules (entity scoping, books date, sign convention, P&L scope) apply unchanged.

**Layout H:** use [`queries/actuals-by-vendor-period.sql`](queries/actuals-by-vendor-period.sql) directly. Substitute `<period_trunc>` from `<AGGREGATION>`, `<period_start>` from `<PERIOD_START>`, `<period_end>` from `<PERIOD_END>`, and `<entity_name>` from `<ENTITY_NAME>`. Same `COALESCE(VENDOR_NAME, 'No vendor')` convention; same hard rules as Layout F. The query returns `(vendor_name, period, signed_amount)` — no `gl_code` or `account_name` columns.

**Layouts A–D:** call [`references/get-actuals.md`](references/get-actuals.md) for the main actuals query. In parallel, call `read_skill(file_path="references/vendor-actuals.md")` and run the vendor actuals query — this loads `<VENDOR_ACTUALS>` into session context so vendor questions (e.g. "which vendor is driving Legal Fees?") are answerable for the rest of the session without a second round-trip. Never write inline SQL outside those files.

**After the actuals are loaded (Layouts F, G, H only):** if the built data structure has a non-empty `No vendor` bucket, go to **Gate 5.5** before the pre-build review. For every other layout, and whenever the `No vendor` bucket is empty, skip Gate 5.5 entirely and proceed to Gate 6.

---

## Gate 5.5 — Infer vendors for 'No vendor' entries (opt-in; Layouts F, G, H only)

**This gate is strictly opt-in and changes nothing by default.** The `No vendor` section renders exactly as before unless the user explicitly asks for inference. Run this gate ONLY when **all** of these hold:

- `<LAYOUT>` is F, G, or H (the layouts with a `No vendor` section), **and**
- the loaded actuals contain at least one `No vendor` entry, **and**
- the user opts in at Step 1 below (or already asked for memo-based inference in their original prompt — in that case skip the Step 1 question and go straight to Step 2).

If any condition is false, **do nothing** — skip to Gate 6.

### Step 1 — Offer inference (`AskUserQuestion`)

Compute the `No vendor` bucket's entry count and total from the already-loaded data. Then ask via `AskUserQuestion` — never a bare-text menu:

- `question`: `"<N> entries totalling <total> have no vendor. Want me to infer vendors from their memos?"` (format `<total>` per the resolved currency)
- `header`: `"No vendor"`
- `multiSelect`: `false`
- `options`:
  1. **Leave them as 'No vendor'** ← recommended (`description`: `"No inference. The 'No vendor' section stays exactly as pulled from the ledger."`)
  2. **Infer vendors from the entry memos** (`description`: `"Read each memo and propose a likely vendor. You approve the list before anything is written."`)

If the user picks option 1 (or the default when they dismiss), **do nothing** — proceed to Gate 6 with the `No vendor` bucket unchanged. Only continue to Step 2 when the user picks option 2 (or asked for inference up front).

### Step 2 — Pull the memos (drill query)

Run [`queries/no-vendor-memo-lines.sql`](queries/no-vendor-memo-lines.sql). Resolve `<memo_column>` from the Gate 0 DWH schema lookup (candidates: `MEMO`, `DESCRIPTION`, `LINE_MEMO`, `NARRATIVE`). Substitute `<entity_name>`, `<period_trunc>` (from `<AGGREGATION>`), `<period_start>`, `<period_end>`.

**If the table has no memo-like column**, tell the user in one plain-English sentence — *"These entries don't carry a memo I can read, so I'll leave them as 'No vendor'."* — and proceed to Gate 6 unchanged. Do not retry with a different column guess more than once.

### Step 3 — Infer a vendor per memo (existing vendors first)

Build the candidate list of **existing vendor names on this entity** — the distinct named vendors already in the loaded data (`<VENDOR_ACTUALS>` for Layout G, or the named-vendor keys of the built pivot for F/H). For each memo:

- **Match to an existing vendor when the memo clearly refers to one** (e.g. memo `"AMZN Mktp US*2Z4"` → existing vendor `Amazon`). Prefer existing vendors so amounts reconcile to the vendor rows already on the sheet instead of spawning near-duplicate spellings.
- **Propose a brand-new vendor name ONLY when the memo unambiguously names a vendor that isn't already present** (e.g. memo `"Stripe invoice #4471"` with no existing `Stripe` row → new vendor `Stripe`).
- **Leave the entry in `No vendor` when you are not confident** — do not force a match. A memo that is a bare reference number, an internal transfer note, or otherwise ambiguous stays untagged.

Never write inferred vendor tags back to Carta — this is a **report-only** enrichment of the workbook. The underlying journal entries are not modified.

### Step 4 — Confirm before applying (`AskUserQuestion`)

Output a preview table as a normal message (not inside the `AskUserQuestion`), grouped so existing-vendor matches and new-vendor proposals are visually distinct:

| Memo | Inferred vendor | New or existing? | Amount |
|---|---|---|---|
| AMZN Mktp US*2Z4 | Amazon | Existing | 1,240 |
| Stripe invoice #4471 | Stripe | New | 600 |

Format every amount in this table per the fund's resolved currency (e.g. `1,240 EUR` / `1,240 USD`) — never hardcode a `$` prefix.

Follow the table with one line: `"<K> of <N> 'No vendor' entries matched (<total matched>). <N−K> stay as 'No vendor'."` Then call `AskUserQuestion`:

- `question`: `"Apply these inferred vendors to the report?"`
- `header`: `"Confirm"`
- `multiSelect`: `false`
- `options`:
  1. **Apply all inferred vendors** ← recommended (`description`: `"Fold every matched amount into the vendor rows, flagged 'inferred from memo'."`)
  2. **Apply only matches to existing vendors** (`description`: `"Skip the new-vendor proposals; only reassign amounts that matched a vendor already on the sheet."`)
  3. **Cancel — keep everything as 'No vendor'** (`description`: `"Discard the inferences and render the section as originally pulled."`)

Wait for an explicit choice. On option 3, proceed to Gate 6 with the `No vendor` bucket unchanged.

### Step 5 — Fold the approved inferences into the data structure

For each approved memo→vendor mapping, move its `signed_amount` (per period, and per GL account for Layouts F/G) **out of** the `No vendor` bucket and **into** the target vendor:

- **Existing vendor:** add the amount to that vendor's matching period/account cell.
- **New vendor:** create a new vendor entry (sorted alphabetically among named vendors) with the amount.
- **Residual:** amounts from unmatched or skipped entries **remain** in `No vendor`. If the bucket becomes empty after reassignment, drop the `No vendor` section.

Mark every vendor row that received an inferred amount so Gate 7 can attach a cell comment (see each layout reference's "Inferred vendors" section) — the flag text is **"inferred from memo"**. Store `<INFERRED_VENDORS>` = the list of `(vendor, amount, sample_memo, is_new)` mappings applied, for the Gate 6 preview and the Gate 8 summary.

The reassigned structure then flows into Gate 6 and Gate 7 exactly like any other vendor data — no separate write path.

---

## Gate 6 — Pre-build review (approval gate)

Preview table grouped by:

- **Existing rows updated** — Line Item | Old Value | New Value | Source.
- **Cells zeroed** — Line Item | Old Value | Reason ("no activity in period").
- **New rows to insert** — Account | Section | Position | Value | Source.
- **GL accounts found in DWH with no row in the sheet** — Account | Total in period.

If any rows carry the `low-confidence — sparse history` flag (account
has < 6 months of activity in the lookback window), surface the count
above the table.

If Gate 5.5 ran and `<INFERRED_VENDORS>` is non-empty, add an **Inferred
vendors** group — Vendor | Amount | New or existing | Sample memo — so the
reassignments are visible one last time before the write. Format every amount
per the fund's resolved currency (e.g. `1,240 EUR` / `1,240 USD`) — never
hardcode a `$` prefix. State the residual that stayed in `No vendor`.

Output the preview tables above as a normal conversation message. Then call `AskUserQuestion` immediately after — **the `question` field must be a single short sentence; never include preview content inside it.**

- `question`: `"Approve applying these updates?"`
- `header`: `"Approval"`
- `multiSelect`: `false`
- `options`:
  1. **Approve and apply the updates** ← recommended (`description`: `"Writes the actuals to the destination chosen in Gate 1."`)
  2. **Edit — change the period range, match strategy, or scope**
  3. **Cancel**

The `← recommended` marker goes inside the `description` field of option 1, not as a suffix on the `label`.

Wait for explicit OK before writing.

**Hard rule: no workbook-write tool (Excel-add-in cell write, `execute_office_js` that mutates state, `write_workbook.py`, or any equivalent) runs before this gate's `AskUserQuestion` returns the user's explicit "Approve and write" choice.** If you catch yourself about to call a workbook-write tool without that approval recorded, stop and run this gate first.

---

## Gate 7 — Write the changes (preserving formulas) AND brand the tabs

### Approval-recorded check (run FIRST, before any write tool)

Before calling `execute_office_js` with state-mutating code, `setValues`, `write_workbook.py`, or any other workbook-write tool, look back at your tool history. Find the most recent `AskUserQuestion` you sent. Does its answer literally include `"Approve and apply the updates"`? If NO, Gate 6 did not pass — send the Gate 6 approval menu now and wait for the explicit answer.

**Do not interpret upstream answers as approval.** A Gate 2 layout response, a Gate 3 period-range answer, or any prior `AskUserQuestion` whose answer is not literally `"Approve and apply the updates"` does NOT clear this gate.

### Gate 7 requires AT LEAST three separate `execute_office_js` calls (excel-addin runtime)

The most common failure mode is bundling cell writes + formatting + logo + verification into one `writeSheet(...)` function — the model writes the cells, returns, hardcodes the logo height, and the user gets a misaligned logo they have to resize manually. **Do not combine the cell-write call with the brand block in a single office.js block.**

- **Call 1:** apply the cell updates from the approved payload. One `execute_office_js`. Return.
- **Call 2 (per tab touched):** logo via the verbatim brand block (paste from below — DO NOT paraphrase, DO NOT hardcode the height, DO NOT anchor to a single cell).
- **Final call (combined verification):** currency format + shape geometry on every tab touched in one `execute_office_js`. See the combined verification block below.

Returning from Call 1 does NOT finish Gate 7. The final combined verification call must appear in your tool history before Gate 8 summary.

### Verbatim brand block — paste this, do not improvise

The single most common logo regression is hardcoding `shape.height = 48` (or any other pixel value) instead of using the actual E1:E3 row-band height. Excel's row heights depend on font sizes set during Call 1, so the band height can vary tab-to-tab. The model that hardcodes 48 produces a logo that either spills past row 3 or sits inside row 1 — the user then has to resize manually. **Paste this block verbatim per tab; substitute only `<TAB_NAME>`:**

```javascript
const base64 = blobs.getText("assets/powered_by_carta.b64.txt").trim();

const sheet = context.workbook.worksheets.getItem("<TAB_NAME>");
const shapes = sheet.shapes;
shapes.load("items/name");
await context.sync();

// De-dup: remove any prior CartaLogo so re-runs don't stack shapes.
for (const s of shapes.items) {
  if (s.name === "CartaLogo") s.delete();
}
await context.sync();

// Anchor to the FULL row band E1:E3 — never a single cell.
const rows = sheet.getRange("E1:E3");
rows.load(["left", "top", "height"]);
await context.sync();

const image = sheet.shapes.addImage(base64);
image.name = "CartaLogo";

image.load(["width", "height"]);
await context.sync();
const ratio = image.width / image.height;

image.lockAspectRatio = false;
image.height = rows.height;        // ← match actual row-band height, never a pixel literal
image.width  = rows.height * ratio;
image.left   = rows.left;
image.top    = rows.top;
image.lockAspectRatio = true;
await context.sync();
```

**Forbidden patterns (these reproduce the manual-resize bug):**

- `image.height = 48` (or any number literal) — height MUST come from `rows.height`.
- `sheet.getRange("E1")` instead of `sheet.getRange("E1:E3")` — single-cell anchor loses the band height.
- Skipping the de-dup loop — re-runs stack a second `CartaLogo` shape on top of the first.
- Skipping `image.lockAspectRatio = false` before sizing — Excel resists width changes if locked.

Only touch the cells the user approved. Do not edit formulas elsewhere
in the sheet (subtotals are formula-driven and will auto-update).

**Before any write**, call both of these in the same message (parallel reads):

1. `read_skill(file_path="references/branding-and-header.md")` — 4-row metadata band, logo placement, `blobs.getText` asset pattern, cell-comment API.
2. `read_skill(file_path="references/<layout-from-gate-2>.md")` — the layout file chosen in Gate 2 (e.g. `add-actuals-columns.md` for Layout A).

Do not reconstruct either spec from memory. Both files must be in your context before generating any `execute_office_js` or `write_workbook.py` code. The `branding-and-header.md` file defines:

- The reserved 4-row metadata band (A1–A4 + blank A5) that every tab must carry — per-skill override (this skill uses column A so the band left-edges with the account-label column underneath). If the existing budget tab doesn't have it, add it as part of this write (shift the data down to row 6+ first via `sheet.getRange("1:5").insert(...)` in Excel add-in mode, or via prepended row writes in local-file mode).
- The Carta logo placement (column E, rows 1–3 height) — apply to every tab this skill touches, including the actuals tab(s) it adds.
- The blobs.getText asset-loading pattern for Excel add-in mode (NOT `Read`).
- The cell-comment pattern for any sparse-history / low-confidence flag.

**If `<RUNTIME>` is `excel-addin`:**

- **Layouts A–D:** load `references/add-actuals-columns.md` §5 ("Build the rebuild payload") and apply its header / column / format spec verbatim — especially the two-row header (row N = merged month labels, row N+1 = `Budget` / `Actual` / `Variance` sub-headers — spelled out in full, never abbreviated). Then use the add-in's cell-write tools to execute the payload.
- **Layout E:** load [`references/tag-view.md`](references/tag-view.md) §"Writing the workbook (excel-addin runtime)" and follow it verbatim — 3-row period/category/tag header, `range.merge(true)` for period and category bands. **Do NOT freeze panes** — same rule as Layouts A–D and the rest of the Carta budgeting skills.
- **Layout F:** load [`references/vendor-view.md`](references/vendor-view.md) §"Writing the workbook (excel-addin runtime)" and follow it verbatim — 2-row period/vendor header, `range.merge(true)` for period bands, per-vendor subtotals, 'No vendor' section at the bottom. **Do NOT freeze panes.**
- **Layout G:** load [`references/inline-vendor.md`](references/inline-vendor.md) §"Write sequence (excel-addin runtime)" and follow it verbatim — clear rows 7+, rebuild account rows (bold, SUM formula) + vendor sub-rows (indented four spaces, hardcoded amounts, locale-specific currency token), then group and collapse. Gate 7's three-call sequence (cell write → brand block → combined verification) applies here too.

**If `<RUNTIME>` is `local-file`:** build an operations payload and apply it:

```bash
uv run "${CLAUDE_PLUGIN_ROOT}/scripts/write_workbook.py" --stdin <<'JSON'
{
  "workbook_path": "<DESTINATION_PATH>",
  "operations": [ ... ]
}
JSON
```

- **Layouts A–D:** use only `write_cell` / `write_formula` / `set_format` operations. Avoid `create_sheet` and `write_range` here — those are for `carta-create-budget`.
- **Layout E:** use `create_sheet`, `write_cell`, `write_range`, `merge_cells`, `set_bold`, `set_format`, `set_column_width` (Account col), `autofit_columns` (data cols) per [`references/tag-view.md`](references/tag-view.md) §"Writing the workbook (local-file runtime)". Always issue `write_cell` for a period label **before** the `merge_cells` op for that same range. **Do NOT include `freeze_panes`** — same rule as Layouts A–D and the rest of the Carta budgeting skills.
- **Layout F:** use `create_sheet`, `write_cell`, `write_range`, `merge_cells`, `set_bold`, `set_format`, `set_column_width` (Vendor/Account col), `autofit_columns` (data cols) per [`references/vendor-view.md`](references/vendor-view.md) §"Writing the workbook (local-file runtime)". Always issue `write_cell` for a period label **before** the `merge_cells` op for that same range. **Do NOT include `freeze_panes`**.

### Row grouping — vendor GL rows (Layout F, excel-addin only, after verification)

If `<VENDOR_GROUPING>` is `collapsed` or `expanded` (set at Gate 3b), run a **4th `execute_office_js` call** after all three required calls pass verification. This call is separate from the three required calls — do not bundle it with cell writes, branding, or verification.

See [`references/vendor-view.md`](references/vendor-view.md) §"Collapse/expand grouping" for the exact code block. Substitute `<PERIOD_LABEL>` and `<VENDOR_GROUPING>` before running.

**Local-file runtime:** skip this step. Row outline grouping is not supported via `write_workbook.py`.

### Combined currency + branding verification (REQUIRED, observable, excel-addin only)

After the brand block runs for every tab, execute **one** `execute_office_js` that checks both currency format and logo geometry in a single `context.sync()`. This replaces the two separate passes (currency readback → branding check) previously required.

Two regressions this catches:
- **Currency format** — amount cells must use a locale-specific token like `[$$-en-US]#,##0.00_);([$$-en-US]#,##0.00);"-"` (USD). Never use bare `$`, `_($*`, or a quoted literal `"$"` — Excel strips quotes from stored format strings, leaving a bare `$` that renders as the system currency symbol.
- **Logo sizing** — hardcoded `shape.height = 48` misaligns the logo when the E1:E3 row band is taller or shorter than 48pt. Height must come from `rows.height`.

**`Range.getImage()` is forbidden here.** The shape geometry (`heightMatchesBand`, `leftMatchesBand`) is the complete, sufficient logo verification. Never output "I cannot visually verify the logo placement" — the geometry check IS the verification. If you find yourself reaching for `Range.getImage()`, stop and use the geometry check instead.

**This block is NOT paste-verbatim — substitute its placeholders before running:** the `tabs` array (the tab names touched this run) and `<sample_amount_cell>`. The `currencyOk` check uses `"[$"` verbatim — no substitution needed.

```javascript
const tabs = [/* "Budget 2026", "2026 Actuals", ... — substitute the actual tab names touched this run */];
const result = {};
for (const tabName of tabs) {
  const sheet = context.workbook.worksheets.getItem(tabName);
  sheet.shapes.load("items/name,items/height,items/left,items/top");
  const rows = sheet.getRange("E1:E3");
  rows.load(["height", "left"]);
  // Pick one amount cell — typically C7 (Layout E) or B8 (Layouts A–D). Substitute from your payload.
  const cell = sheet.getRange("<sample_amount_cell>");
  cell.load("numberFormat");
  await context.sync();

  const logo = sheet.shapes.items.find(s => s.name === "CartaLogo");
  result[tabName] = {
    // Currency check
    numberFormat:      cell.numberFormat[0][0],
    currencyOk:        cell.numberFormat[0][0].includes("[$"),  // locale-specific currency token, e.g. [$$-en-US]
    // Branding checks
    found:             !!logo,
    shapeHeight:       logo ? logo.height : null,
    rowBandHeight:     rows.height,
    heightMatchesBand: logo ? Math.abs(logo.height - rows.height) < 2 : false,
    shapeLeft:         logo ? logo.left : null,
    rowBandLeft:       rows.left,
    leftMatchesBand:   logo ? Math.abs(logo.left - rows.left) < 2 : false,
  };
}
return result;
```

Per-tab pass criteria — ALL must be true:

- `currencyOk === true` — sample cell format contains `[$` (locale-specific currency token)
- `found === true` — `CartaLogo` shape exists
- `heightMatchesBand === true` — logo height equals E1:E3 row-band height ±2pt
- `leftMatchesBand === true` — logo anchors at column E's left edge ±2pt

**Recovery actions:**

- `currencyOk: false` → re-apply the locale-specific token for the resolved currency — pick the matching line, substitute `<full_amount_range>`, then re-run this combined verification:
  - USD: `sheet.getRange("<full_amount_range>").numberFormat = [["[$$-en-US]#,##0.00_);([$$-en-US]#,##0.00);\"-\""]];`
  - EUR: `sheet.getRange("<full_amount_range>").numberFormat = [["[$€-x-euro2]#,##0.00_);([$€-x-euro2]#,##0.00);\"-\""]];`
  - GBP: `sheet.getRange("<full_amount_range>").numberFormat = [["[$£-en-GB]#,##0.00_);([$£-en-GB]#,##0.00);\"-\""]];`
  - CAD: `sheet.getRange("<full_amount_range>").numberFormat = [["[$CA$-en-CA]#,##0.00_);([$CA$-en-CA]#,##0.00);\"-\""]];`
- `found: false` → brand block was skipped — re-run the verbatim brand block, then re-verify.
- `heightMatchesBand: false` or `leftMatchesBand: false` → brand block used a hardcoded pixel or wrong anchor — delete the `CartaLogo` shape and re-run the verbatim brand block, then re-verify.

**Do not start Gate 8 summary text until every tab passes all four criteria.** The verification call is observable evidence; without it in your tool history with passing checks, Gate 7 is not complete.

---

## Gate 8 — Summary + next steps

**Gate 8 precondition (DO NOT SKIP).** Before sending the summary text below, scan your tool history. Three anchors MUST be present in this order (excel-addin runtime):

1. An `AskUserQuestion` whose answer included `"Approve and apply the updates"` — Gate 6 approval.
2. A `sheet.shapes.addImage(base64)` call for **each** tab the skill touched (one per tab) — Gate 7 branding.
3. The combined currency + branding verification `execute_office_js` whose result showed `currencyOk: true`, `found: true`, `heightMatchesBand: true`, and `leftMatchesBand: true` for every tab — Gate 7 combined verification.

If any anchor is missing, you have skipped a gate. **Do NOT write "Carta logo placed at..." in the summary when no `shapes.addImage` call appears in your tool history — that's hallucinating completion.** STOP, go back, run the missing gate, then return here.

**Layouts A–D — If `<RUNTIME>` is `excel-addin`:**

> Refreshed 23 lines on [Budget 2026](<citation:Budget 2026!A1:Z80>)
> (Example MgmtCo). 2 lines zeroed (Audit, Tax Prep — no Q1 activity).
> 1 new account inserted under Operating Expenses (AI Tooling).
> 2 suspicious-zero flags — Salaries and Leased-employee payments
> dropped to $0; could be posting lag.

**Layouts A–D — If `<RUNTIME>` is `local-file`:**

> Refreshed 23 lines on `Budget 2026` in
> `file:///path/to/<budget-workbook>.xlsx` (Example MgmtCo). 2 lines zeroed (Audit, Tax Prep — no Q1 activity). 1 new
> account inserted (AI Tooling, Operating Expenses). 2 suspicious-zero
> flags — Salaries and Leased-employee payments.

**Layout E — If `<RUNTIME>` is `excel-addin`:**

> Created [2026 Actuals by Department](<citation:2026 Actuals by Department!A1>) (Example MgmtCo) — 23 accounts × 4 department values (Engineering, Marketing, G&A, Untagged), annual aggregation. 1 account flagged low-confidence (sparse history). Carta logo placed at E1.
>
> Substitute the period block phrasing to match `<AGGREGATION>` from Gate 3a: "annual aggregation" for `YEAR`, "across 4 quarters" / "across Q1+Q2" for `QUARTER`, "across 12 months" / "across Jan–May" for `MONTH`.

**Layout E — If `<RUNTIME>` is `local-file`:**

> Created `2026 Actuals by Department` tab in `file:///path/to/<budget-workbook>.xlsx` (Example MgmtCo) — 23 accounts × 4 department values, annual aggregation. 1 account flagged low-confidence (sparse history). Adjust the period phrasing to match `<AGGREGATION>` (see excel-addin example above).

**Layout F — If `<RUNTIME>` is `excel-addin`:**

> Created [2026 Actuals by Vendor](<citation:2026 Actuals by Vendor!A1>) (Example MgmtCo) — 23 accounts across 8 vendors (A2Z, Rippling, Alcatraz LLP, … No vendor), annual aggregation. 1 account flagged low-confidence (sparse history). Carta logo placed at E1.
>
> Substitute the period block phrasing to match `<AGGREGATION>` from Gate 3a: "annual aggregation" for `YEAR`, "across 4 quarters" / "across Q1+Q2" for `QUARTER`, "across 12 months" / "across Jan–May" for `MONTH`. List the top 3–5 vendor names; collapse the rest as "… No vendor".
>
> If `<VENDOR_GROUPING>` is `collapsed` or `expanded`, append: "Vendor detail rows are grouped — use the **+/−** toggles on the left margin or the **1/2** outline buttons in the top-left corner to expand or collapse all vendor sections at once."

**Layout F — If `<RUNTIME>` is `local-file`:**

> Created `2026 Actuals by Vendor` tab in `file:///path/to/<budget-workbook>.xlsx` (Example MgmtCo) — 23 accounts across 8 vendors, annual aggregation. 1 account flagged low-confidence (sparse history). Adjust the period phrasing to match `<AGGREGATION>` (see excel-addin example above).

**Layout H — If `<RUNTIME>` is `excel-addin`:**

> Created [2026 Vendors](<citation:2026 Vendors!A1>) (Example MgmtCo) — 8 vendors (A2Z, Rippling, Alcatraz LLP, … No vendor), annual aggregation. 1 vendor flagged low-confidence (sparse history). Carta logo placed at E1.
>
> Substitute the period block phrasing to match `<AGGREGATION>` from Gate 3a: "annual aggregation" for `YEAR`, "across 4 quarters" / "across Q1+Q2" for `QUARTER`, "across 12 months" / "across Jan–May" for `MONTH`. List the top 3–5 vendor names; collapse the rest as "… No vendor".

**Layout H — If `<RUNTIME>` is `local-file`:**

> Created `2026 Vendors` tab in `file:///path/to/<budget-workbook>.xlsx` (Example MgmtCo) — 8 vendors, annual aggregation. 1 vendor flagged low-confidence (sparse history). Adjust the period phrasing to match `<AGGREGATION>` (see excel-addin example above).

**If Gate 5.5 ran and inferences were applied**, append one sentence to the summary (Layout F/G/H): *"`<M>` entries were inferred from memos and folded into their vendors (flagged with a cell comment); `<residual>` stayed as 'No vendor'."* Do not claim any inference when `<INFERRED_VENDORS>` is empty.

**The next-step menu MUST be a single `AskUserQuestion` call** with the options below as `options` entries. Never render them as a numbered markdown list, a bulleted list, or inline prose — bare-text menus break the chooser UI in Claude for Excel and force the user to type the number. The `← recommended` marker goes inside the `description` field of one option, not as a suffix on the `label`.

1. **Run a pacing analysis (Budget vs Actuals)** ← recommended
2. **Drill into a specific line item (largest entries / month-by-month)**
3. **Model a what-if scenario on this budget**
4. **I'm done**

**Call `AskUserQuestion` with these exact parameters:**

- `question`: `"What would you like to do next?"`
- `header`: `"Next step"`
- `multiSelect`: `false`
- `options`: the four `label` + `description` pairs above (place `← recommended` in the `description` field of the recommended option, NOT in the `label`)

**DO NOT** render the menu as inline markdown text, a numbered list, a bulleted list, or closing prose. If your response is about to contain `1. ...`, `2. ...`, `3. ...`, `4. ...` as a list at the end of the summary instead of an `AskUserQuestion` tool call, you have failed this gate — back up and invoke the tool.

Mark `← recommended` based on context — option 1 by default after a refresh; option 2 if the user previously asked about a specific line.

**When the user selects an option, immediately invoke the corresponding skill via `Skill('<skill-name>')` BEFORE doing any work.** Do not freelance the output — load the downstream skill's SKILL.md so its gates, layout spec, branding rules, and approval flow apply. Routing:

| Option | Skill to invoke |
|---|---|
| 1 — Run a pacing analysis | `Skill('carta-investors:carta-budget-analysis')` |
| 2 — Drill into a specific line item | `Skill('carta-investors:carta-budget-analysis')` with the `drill-down-line` reference |
| 3 — Model a what-if scenario | `Skill('carta-investors:carta-budget-scenarios')` |
| 4 — I'm done | No invocation; close cleanly |

---

### DWH result formatting

Queries > 50 rows: request `format: "ndjson"`, bucket into a blob. Don't paste large results — triggers `context_snip`. Use `"markdown"` only for ≤50-row previews.

## Hard rules

- Same DWH primitives as `carta-create-budget` — Carta DWH journal-entries table only, no external-DWH fallback, `FUND_NAME` scoping, `AMOUNT` (not the base-currency variant), sign flip, preserve reversals.
- Local-file: never overwrite cells flagged as formulas in `read_workbook.py` output. Subtotals / NOI keep their `=SUM(...)` semantics.
- **Two-row header is mandatory** for month-bucketed tables. Row N = merged month label per `Budget`/`Actual`/`Variance` triplet. Row N+1 = sub-headers spelled out in full (`Budget`, `Actual`, `Variance`). **Never abbreviate to `B`/`A`/`V`**. Never write both into the same row — subsequent merges destroy sub-headers.
- `range.merge(true)` discards trailing cell values. Insert a new row first.
- **Month-label date-serial trap (header rows):** before writing any month or period text label ("Jan 2026", "Q1 2026", etc.) to a header row, apply `numberFormat = [["@"]]` (text format) to the entire header range first, then write the values. Without this, Excel auto-coerces "Jan 2026" → date serial 46023. This applies to row 6 column headers and any period-band rows (rows 6/7 in Layouts E and F). Applies to both `execute_office_js` (set `range.numberFormat` before `range.values`) and `write_workbook.py` (emit a `set_format` op with `"@"` before the `write_cell` ops for the header row).
- **Currency — derive from the data, never default to USD.** Resolve the workbook's presentation currency before writing (entity properties via `welcome`, or the currency on the budget data); if it can't be resolved, ask the user. State the resolved currency in cell A4: `Amounts in <resolved_currency>`.
- **Currency format:** use a locale-specific currency token — `[$$-en-US]#,##0.00_);([$$-en-US]#,##0.00);"-"` for USD, `[$€-x-euro2]#,##0.00_);([$€-x-euro2]#,##0.00);"-"` for EUR, `[$£-en-GB]#,##0.00_);([$£-en-GB]#,##0.00);"-"` for GBP. Resolve the currency from the data — never default to USD. Do **not** use a bare `$` or `_($*` format — Excel substitutes the system currency symbol for those. Do **not** use a quoted literal like `"$"` — Excel strips the quotes when storing the format, leaving a bare `$` that renders as the system currency. Apply to all data ranges after the data write.
- **Match the existing tab's period granularity.** If the budget tab is quarterly (not monthly), interleave Budget/Actual/Variance per quarter, not per month. For a partial period (e.g. YTD through a mid-quarter month against a quarterly tab), pull actuals at month grain and aggregate to the tab's buckets. Do not prorate a quarter's budget by month-fraction to approximate a partial period — derive the period's budget from the underlying monthly source.
- **Buffer-aware variance basis.** If the on-tab budget carries an inflation/contingency buffer (an input cell, or a header note like "Budget includes X% buffer"), variances are computed against the buffered figure — state this in the preview so favorable variances aren't misread, and offer to compare against the raw (pre-buffer) budget instead.
- **Border syntax (Office.js):** `style = "Continuous"`, then `weight = "Thin"`. Never `style: "Thin"`.
- **Recalc + column widths:** the last statements in the cell-write `execute_office_js` block (Call 1), in this order — never a separate call: restore automatic calc → `context.workbook.application.calculate(Excel.CalculationType.full)` → `sheet.getRange("A:AN").format.autofitColumns()` (widen the range to the last amount column) → `context.sync()`. **Recalc before autofit:** without the forced recalc the `=SUM(...)` / variance / NOI cells stay at 0 and the accounting format shows `-` (forcing the user to edit+Enter each one); autofitting before the recalc sizes the amount columns to the dash so real figures overflow as `####`. Never autofit a header-only range.
- **Branding standards — follow [`references/branding-and-header.md`](references/branding-and-header.md)** for every tab. Per-skill overrides: metadata band in column A (not B), logo at column E. Asset access via `blobs.getText("assets/...")`.

---

## Schema discovery

The skill queries the Carta DWH journal-entries table. If column
names are needed, look up the table via the Carta MCP DWH schema
command once at Gate 0 — production schema is canonical. Don't embed
column listings inline; the DWH contract can drift.

## Error handling

| Symptom | Likely cause | What to tell the user |
|---|---|---|
| No Carta MCP server found | The Carta connector isn't enabled in this session | "I can't see your Carta connector. Open **Settings → Connectors** in Claude, enable Carta, then ask me again." |
| Sheet has no recognisable header row | The budget layout uses non-date column headers | Surface what the headers look like and ask the user which row is the header and which columns are actuals. |
| `low-confidence — sparse history` flagged on many rows | Entity is new or sparsely posted | Surface the count in the preview and let the user decide whether to proceed. Don't auto-suppress. |
| Multiple budget tabs in the workbook | Ambiguous "the budget" | Ask the user which tab to update; do not silently pick one. |
| Cell the skill wants to write is a formula | Subtotal / NOI row | Surface the row and confirm; never silently overwrite a formula. |
| Local-file mode: file path is missing or unreadable | Wrong path supplied | Echo the path back and ask for the correct one. |
| Query times out | DWH load | Tell the user it's slow and offer to retry — never auto-retry. |
| Auth / permission error from the MCP | Carta session expired or lacks DWH access | Ask the user to reconnect Carta in Settings → Connectors. |
| Connector shows as connected, but tool calls fail with `McpAuthError` or "tool not available" | The MCP server's tool prefix doesn't match what this skill's `allowed-tools` enumerates. Re-auth is not the fix — see Gate 0 troubleshooting note below the table. | "I'm reconnecting to your Carta workspace — one moment." |

**Connector-mismatch troubleshooting (operator-facing, not user-facing).** Re-run `refresh_mcp_connectors` to confirm which Carta connector is actually connected, then probe the matching prefix's `welcome` per the Gate 0 mapping. Never tell the user to re-auth without verifying the prefix mismatch first.

Never auto-retry a failed query.
