---
name: portfolio-valuation-assessment-v5
description: "Produces a structured valuation assessment (v5) for portfolio companies by pulling data from Kruncher (growth score, financial snapshot, analysis detail, pros/cons, competitors, employee growth, web traffic). Deal score is explicitly NOT used. Every valuation call carries a 20–30 word rationale and all dates use US format. Use this skill whenever the user asks to assess portfolio company valuations, run a portfolio valuation review, generate investment memos from Kruncher data, compare stated valuations to traction metrics, or produce valuation calls (HIGH/FAIR/LOW) for portfolio companies. Also trigger when the user mentions \"valuation assessment\", \"portfolio review\", \"mark review\", or asks the skill by name."
---

# Portfolio Valuation Assessment v5

This skill produces a structured, investment-memo-grade valuation assessment for `<YOUR_FIRM>` portfolio companies. It synthesises data from Kruncher (automated company analysis) into a concise, opinionated output that a GP can scan in 30 seconds per company.

The output is analytical, not descriptive. Every bullet should contain a judgement, not just a fact.

## Configuration

Before running this skill, the maintainer should populate the following placeholders with firm-specific values:

| Placeholder | What it is | Example shape |
|---|---|---|
| `<YOUR_FIRM>` | Name of the investment firm | "Acme Capital" |
| `<LEAD_PARTNER>` | GP whose review priorities drive the output | First name only is fine |
| `<FUND_A>`, `<FUND_B>` | Funds in scope for this review | Short codes (e.g. "F1", "F2") |
| `<EXCLUDED_FUND_1>`, `<EXCLUDED_FUND_2>` | Funds explicitly out of scope | E.g. a thematic vehicle or rolling fund |
| `<PORTFOLIO_STAGE>` | Kruncher pipeline-stage name that contains all live portfolio companies | E.g. "Portfolio (Quarterly)" |
| `<PORTFOLIO_LIST_A>`, `<PORTFOLIO_LIST_B>` | Kruncher project-list names that encode fund membership | E.g. "Portfolio (Fund A)" |
| `<FUND_A_LIST_UUID>`, `<FUND_B_LIST_UUID>` | UUIDs resolved at runtime from `get_filters` | UUID strings |
| `<YOUR_TEAM>` | List of internal team-member names used to disambiguate transcript speakers | Configured per run |

## Core changes from v4 (read first)

These four rules override anything earlier in v4 and apply to every deliverable. They were captured directly from `<LEAD_PARTNER>`'s feedback during the v4 → v5 review.

1. **Deal score is excluded from analysis.** Do not fetch the `dealscore` chapter, do not reference `dealScore` in any bullet, summary, memo, or call rationale, and do not let it influence the HIGH/FAIR/LOW judgement. The field is still read from `list_projects` for hygiene checks only (so we can notice when it drifts) but it never appears in user-facing output and never enters the call decision. The DOCX summary table and XLSX memo both omit any deal-score column. (Section: Step 3, Step 7, Bullet 1, Summary table.)
2. **Every valuation call carries a 20–30 word rationale.** Bullet 5 (Valuation Call), the summary-table Summary cell, and the XLSX `VALUATION CALL:` line must each end with a one-sentence explanation of *why* — naming the data point or comp that drove the call. The target length is 20–30 words; under 15 reads as hand-waving, over 35 dilutes the signal. `<LEAD_PARTNER>` was explicit: she will use the "why" to spot data errors, missing context, and her own oversights. The rationale is especially load-bearing on **FAIR** calls. (Section: Bullet 5, Summary table, XLSX memo.)
3. **All dates use US format.** Switch every rendered date to either `MM-DD-YYYY` (numeric) or `Mon DD, YYYY` / `Month DD, YYYY` (spelled-out month). Never use `DD-MM-YYYY`, `DD/MM/YYYY`, `D Month YYYY` (European leading-day), or any ISO `YYYY-MM-DD` form in user-facing text. Convert any date pulled from Kruncher entity values before rendering. (Section: Step 6, Metrics Line, all output sections.)
4. **The valuation question is "if this company were to raise or be purchased today, would the call be HIGH / FAIR / LOW" — not "is the company healthy?"** A near-out-of-business company is not a LOW-valuation opportunity; LOW means the *price* is below what the fundamentals support. When a company has < 4 months of runway with no documented bridge, or when the entity values describe a clearly dying business, the right call is **WRITE-OFF / EXITED** or **HIGH** (the current mark overstates value), never **LOW**. (Section: Bullet 5 call definitions.)

The changelog at the bottom captures every other change relative to v4.

## Scope: Fund-based, list-driven

**The portfolio review is bifurcated by fund. The fund a company belongs to is encoded in Kruncher project lists, not in the stage.** Funds:

- **`<FUND_A>`** — included
- **`<FUND_B>`** — included
- **`<EXCLUDED_FUND_1>`** — excluded from this review
- **`<EXCLUDED_FUND_2>`** — excluded from this review

The output groups companies by fund (`<FUND_A>` first, then `<FUND_B>`). Each fund gets its own per-company section AND its own summary table; a third section combines them.

By design, all live portfolio companies should be consolidated under a single `<PORTFOLIO_STAGE>` pipeline stage. The stage is therefore a sanity check, not the primary filter. The primary filter is **list membership in `<PORTFOLIO_LIST_A>` or `<PORTFOLIO_LIST_B>`**.

## Data Collection

All data comes from the Kruncher MCP server. Run calls in parallel wherever possible to minimise latency, but expect every `get_report` payload to exceed the inline tool-output limit. The workflow below assumes responses are auto-saved to disk and parsed via python rather than read inline.

### Step 0: Resolve the fund lists

Call `get_filters` once at the start of every run. The returned payload includes "project lists with counts" — find the entries for `<PORTFOLIO_LIST_A>` and `<PORTFOLIO_LIST_B>` and cache their `projectlistId` UUIDs.

**`list_projects` does not currently accept a list-membership filter.** Workaround: every project record returned by `list_projects` contains a `projectlists[]` array with `{id, name, color}` entries — filter on this directly.

```python
FUND_A_LIST = "<FUND_A_LIST_UUID>"   # resolved from get_filters
FUND_B_LIST = "<FUND_B_LIST_UUID>"
for p in all_projects:
    list_ids = [pl.get("id") for pl in (p.get("projectlists") or [])]
    funds = []
    if FUND_A_LIST in list_ids: funds.append("<FUND_A>")
    if FUND_B_LIST in list_ids: funds.append("<FUND_B>")
    if funds: in_scope.append((p, funds))
```

**Note for the skill maintainer:** if/when Kruncher adds a `listId` (or `listIds`) parameter to `list_projects`, replace Step 0+1 with a single `list_projects({listId: <FUND_A_LIST_UUID>})` and `list_projects({listId: <FUND_B_LIST_UUID>})` pair.

**Stage-hygiene seeds:** maintain a known-drift map of companies that recurrently get placed in the wrong fund list. Treat these as known hygiene items in the output; do **not** silently move them. Populate the map per-run from `<LEAD_PARTNER>`'s feedback.

### Step 1: Enumerate the portfolio

Call `get_filters` to resolve the `<PORTFOLIO_STAGE>` UUID (stageCode usually `portfolio`). Then call `list_projects` with `projectStageId` set to that UUID. Paginate (`pageSize` max 50) until all projects are listed.

**`list_projects` payloads can exceed the inline limit** — even 50 projects can spill several MB. Expect auto-save to disk and parse with python. Cache for every project:

- `id`, `companyName`, `companyWebsite`, `companyIndustry`, `companyCountry`, `companyBusinessModel`
- `growingScoreOutcome`, `growingScore`, `growingScoreExplanation` (these are NOT returned by `get_report.projectDetail` — `list_projects` is the only source)
- `companyStage`, `companyRevenueRange`, `employeeCount`, `updatedAt`, `hasDuplicates`
- `projectlists[]` (for the fund filter from Step 0)
- `dealScore` — cache for hygiene-only auditing (drift detection). **Never surface this value in any deliverable.** See core change #1.

Apply the fund filter from Step 0. **Drop anything in `<EXCLUDED_FUND_1>`, `<EXCLUDED_FUND_2>`, or no fund list.**

**Stage hygiene check (advisory, not blocking):** If any in-scope company is not in `<PORTFOLIO_STAGE>`, surface it as a hygiene flag at the end (e.g., "Company X is in `<FUND_A>` but sits in stage Y — `<SKILL_OWNER>` to move"). Process it anyway.

**`hasDuplicates=True` companies:** flag at end as a merge candidate. Don't drop them — process and flag.

### Step 2: Dedupe, pivot lineage, and purchased-domain aliasing

Three distinct dedupe problems:

**(a) Plain duplicates** — the same company appearing under two project IDs in the same stage. Dedupe by `companyName` (case-insensitive, ignoring trailing legal suffixes) AND by domain root if available. Prefer the record with the most recent `updatedAt` (no tie-breaker on `dealScore` — see core change #1; fall back to alphabetical project id). Flag duplicates as hygiene items for the user to merge.

**(b) Pivot lineage — same team, same capital, different brand.** A real portfolio routinely contains companies that have rebranded once or twice — sometimes across categories — while retaining the same team and the same invested capital. These look like distinct projects in Kruncher but are **a single investment** from the GP's perspective. For quarterly review, the analysis should look at the **culmination** of all experiments under that team and capital — not multiple separate company writeups.

**(c) Purchased-domain aliasing — new portfolio company bought a defunct company's URL/brand.** Kruncher's enrichers may find a defunct entity via news / WHOIS history and surface a stale "exited" / "closed" signal that actually belongs to the prior owner of the domain. Mechanism:

1. When `growingScoreOutcome == "companyAcquired"` or `companyClosed` but `companyWebsite` registration / first-use is younger than the exit date (`investments[].date`, `history` text, `whatsnew.currentAnalysisDate`), suspect a purchased-domain alias.
2. Confirm against the founder-update channel (Step 7) — if the founder has sent an update *after* the supposed exit date, the exit signal is stale.
3. Override the outcome locally for this report and surface as a hygiene flag: "`<Company>` flagged as exited/closed by Kruncher, but founder updates dated `<date>` indicate active operation. Likely purchased-domain aliasing — Kruncher to scope by acquisition date."

Mechanism (pivot lineage):

1. Maintain a pivot-lineage map at the top of the run, populated by the maintainer. Shape:
   - `<CurrentBrand>` ⟵ ancestors: `<PriorBrand>`
   - `<CurrentBrand>` ⟵ ancestors: `<PriorBrand1>`, `<PriorBrand2>`
2. **Disambiguate near-name collisions.** Two distinct portfolio companies may have near-identical names (e.g., the same string with different casing or spacing). Don't auto-merge on substring similarity — require either an explicit entry in the lineage map or matching founder/domain identifiers.
3. For each canonical (current-brand) project, pull `get_report` separately for the ancestors too. Merge the historical narrative (`whatsnew` history, `history`, prior `setbacksOneSentence`) into a single "investment lineage" preamble for the canonical project's writeup.
4. In the per-company section, prepend a single italic line under the header: *Previously: `<PriorBrand>` (`<prior category>`). Same team, same capital.*

If a pivot is suspected but not in the map (e.g., same founder email domain appears under two different project names), surface it as a hygiene flag and use the user-confirmed canonical entity.

### Step 3: Pull the report per company

Call `get_report` with the `projectId`. Pass a comma-separated `sections` list rather than fetching the full report. **Validated bundle for v5** (deal score chapter removed — see core change #1):

```
projectcompanyfit,whatsnew,redflags,roundSummary,tractionSummary,
narrativeSummary,riskAndFlagsSummary,summaryProsCons,businessMetricsSummary,
teamSummary,marketSummary,investment,investments,competitors,businessmodel
```

`metrics` is optional and the heaviest chapter — only fetch if `cashOnHand`/`burnRate` aren't captured in `financialSnapshotOneSentence` (in practice they usually are). **Do not** add `dealscore` to the bundle even if a future caller wants it back — that's a deliberate v5 exclusion.

Call `get_report_sections` once at the start of a session to confirm the current chapter keys — they evolve. **Validated keys (as of last review):** `summary, solutionProblemSummary, businessModelSummary, narrativeSummary, tractionSummary, businessMetricsSummary, milestonesSummary, teamSummary, marketSummary, roundSummary, riskAndFlagsSummary, summaryPitchDeck, summaryProsCons, productChapter, founders, businessmodel, gotomarket, marketopportunity, competitors, investment, metrics, tractionProxy, news, milestones, matchmaking, investmentMemoChap, portfolioMemoChap, meetingPreparationNotes, notes`.

**Run in parallel batches of 8.** Each batch is one assistant turn with 8 `get_report` calls; expect 7-8 to spill to disk per batch. At ~100 companies that's ~13 batches.

**Expect `get_report` to exceed the inline limit.** Use `find / -name "mcp-*get_report-*.txt"` from the bash sandbox to locate the auto-saved file, then parse with python — never try to Read the raw JSON inline.

### Step 4: Parse the saved response (V3 structure)

The response payload is `[{"type":"text","text":"<json>"}]`. After `json.loads` on the text, top-level keys on `data`:

- `projectDetail` — identifiers and company facts. **Does NOT contain dealScore or growingScore** — those come from `list_projects` (Step 1). Critically, `projectDetail.companySummary` is your *only* fallback if `analysisDetail` is null (Step 8: mid-reanalysis).
- `analysisId`, `analysisDetail` (list of chapters). **May be `null` if `projectDetail.analysisStatus == "progress"`** — see Step 8.
- `projectcompanyfit` — `{id, label, score, total, originalLabel, originalScore, userEdited, projectcompanyfitdimensions[]}`. `projectcompanyfitdimensions[]` is the named dimension array (Portfolio Fit, Team, Traction, Market, Business Model).
- `whatsnew` — `{text, textEdited, sentimentCode, previousAnalysisDate, currentAnalysisDate, references, trends, ...}`.
- `projectvotesummary` — voting structure. Stub defaults to `"No votes yet!"` even when empty — check `hasValue`.
- `investments` — customer-tracked investment records. **Often empty** even when `<YOUR_FIRM>` invested; fall back to entity values (`history`, `investors`, `roundOneSentence`, `investmentNeeds`).
- `totalInvestment` — `{amount, currency}`. Frequently `{amount: 0, currency: "USD"}` for the same reason.
- `companyKpis` — KPI events list.
- `redflags`, `warnings` — **often empty arrays** for portfolio companies; risk signal lives in entity values + fit-dimension `outcome` fields marked `"Deal Breaker"`.

**V3 nested entity structure.** Two valid shapes — values may sit directly on the chapter row OR inside `analysisreportrows[]`. Always check both:

```
data.analysisDetail[].analyseschapter.analyseschapterrows[].text                  (sometimes)
data.analysisDetail[].analyseschapter.analyseschapterrows[].analysisreportrows[].text   (most common)
```

`row.dataPoints` is a *type-hint* string, not the value. Reference extractor:

```python
def extract_entities(analysis_detail):
    out = {}
    if not analysis_detail: return out
    for item in analysis_detail:
        ch = item.get("analyseschapter", {}) if isinstance(item, dict) else {}
        for row in (ch.get("analyseschapterrows") or []):
            ek = row.get("entityKey")
            if not ek: continue
            # Try direct value fields on the row first
            picked = None
            for k in ("textEdited","text","valueString","valueText","valueNumber","valueDate","content"):
                v = row.get(k)
                if v not in (None, "", []):
                    picked = v; break
            if picked is None:
                for ar in (row.get("analysisreportrows") or []):
                    for k in ("textEdited","text","valueString","valueText","valueNumber","valueDate","content"):
                        v = ar.get(k)
                        if v not in (None, "", []):
                            picked = v; break
                    if picked is not None: break
            if picked is not None:
                out[ek] = picked
    return out
```

### Step 5: Field map

Entity keys reliably populated on portfolio analyses:

**Financial snapshot & round** — `financialSnapshotOneSentence`, `valuation`, `fundsRaised`, `currentRoundWord`, `roundOneSentence`, `vcBackable`, `totalSales`, `burnRate`, `cashOnHand`, `revenueModelTwoWords`.

**Traction & business metrics** — `tractionOneSentence`, `revenueGrowth12m`, `topLineSummary`, `marginSummary`, `businessOneSentence`, `customersNumber`, `customersOneWord`, `webTrafficGrowth`, `salesPipelineOneSentence`, `usageAdoptionOneSentence`, `churnSummary`.

**Narrative** — `narrativeSummaryOneSentence`, `keyWinsOneSentence`, `setbacksOneSentence`, `founderRequestsOneSentence`, `whatsNextOneSentence`.

**Team** — `numberOfEmployees`, `employeesGrowth`, `jobOpeningsSummary`, `keyHiresDeparturesOneSentence`, `teamStructureInsightSummary`.

**Risk** — `riskFlagOneSentence`, `strategicRisksOneSentence`, `operationalRisksOneSentence`. Also read fit-dimension `outcome` strings for "Deal Breaker" callouts.

**Market & competition** — `marketOppOneSentence`, `cagrPercentage`, `majorCompetitors`, `competitorsCsv`, `competitor_insightDirectCompetitors`, `competitor_insightComparableCompanies`, `competitor_insightComparableSummary`, `keyCompetitorsInsightSummary`.

**Capital structure** — `investmentNeeds`, `fundsAllocation`, `timing`, `history`, `investors`, `exitstrategy`, `partnerships`, `contracts`.

**Business model / product** — `competitiveAdvantage` (populated for ~90% of companies), `revenueStreams`, `productPrice`, `usecases`.

**Insights chapter** — `prosAndCons`. The row's `analysisreportrows[]` mixes pros, cons, and diligence questions; group by classifier (`typeOf` / `type` / `section` — probe before assuming) and take the top 4-6 of each.

### Step 6: Recency and date formatting

**Recency rule:** the report must reflect **today's reality**, not a period-end snapshot.

- Do NOT apply a Q-end cutoff (e.g., "as of March 30"). The cutoff comes from the most recent reliable update Kruncher has.
- If `financialSnapshotOneSentence` is dated before a known funding close that surfaces in `roundOneSentence` or `whatsnew.text`, the snapshot is stale. Flag and estimate post-close cash/runway.
- Concrete example: a company shows "$2M cash, 4mo runway" as of Dec 31, but `whatsnew.text` says "raised $50M in January." The cash/runway figure must be reported as post-close, not the Dec 31 number, otherwise the report flags a critical runway risk that isn't real.
- For the metrics line header date, use the date of the most recent material update that informs each figure, not the form-deadline (e.g., a Q1 form deadline of March 30).

**Date formatting rule (core change #3):** every date that appears in any deliverable — DOCX headers, metrics line, bullets, summary tables, XLSX memos, cross-portfolio flags — must use one of two US formats:

| Use case | Format | Example |
|---|---|---|
| Spelled-out (preferred for metrics line headers, bullets) | `Month DD, YYYY` or `Mon DD, YYYY` | `January 15, 2026` / `Jan 15, 2026` |
| Numeric (preferred for tables, compact rows) | `MM-DD-YYYY` | `01-15-2026` |
| Month-year only (when day isn't material) | `Month YYYY` or `Mon YYYY` | `January 2026` |

Never use: `DD-MM-YYYY`, `DD/MM/YYYY`, `15 January 2026`, `2026-01-15`, `15/01/26`, or any other European or ISO layout. Kruncher entity values sometimes ship ISO or European-format dates (the source data is mixed) — convert before rendering. Reference helper:

```python
from datetime import datetime
US_LONG = "%B %d, %Y"      # January 15, 2026
US_SHORT = "%b %d, %Y"      # Jan 15, 2026
US_NUM  = "%m-%d-%Y"        # 01-15-2026
US_MY   = "%B %Y"           # January 2026

def to_us(value, style="long"):
    """Coerce any plausible date string/object to US format. Returns None for unparseable."""
    if value is None: return None
    if isinstance(value, datetime): dt = value
    else:
        for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%SZ",
                    "%d/%m/%Y", "%d-%m-%Y", "%d %B %Y", "%d %b %Y",
                    "%m/%d/%Y", "%B %d, %Y", "%b %d, %Y"):
            try: dt = datetime.strptime(value.strip(), fmt); break
            except (ValueError, AttributeError): dt = None
        if dt is None: return value  # leave unchanged; safer than guessing wrong
    return dt.strftime({"long": US_LONG, "short": US_SHORT, "num": US_NUM, "monthyear": US_MY}[style])
```

Run every Kruncher-sourced date through `to_us(...)` before it lands in a DOCX paragraph, table cell, or XLSX memo line. The same rule applies to `whatsnew.currentAnalysisDate`, `whatsnew.previousAnalysisDate`, `investments[].date`, and any date appearing inside narrative entity strings.

### Step 7: Source weighting — founder updates outrank internal notes

When multiple sources inform a single fact, weight them:

1. **Founder-authored updates** (forwarded emails, founder-submitted forms, founder slide decks) — highest weight.
2. **Internal `<YOUR_FIRM>` meeting transcripts** (call recordings, meeting notes) — supporting context, lower weight.
3. **Public/third-party signal** (press releases, web traffic, employee count from LinkedIn) — confirmatory only.

Where transcripts are present, identify whether the speaker is a `<YOUR_FIRM>` team member or the founder. **The skill should maintain a known list of `<YOUR_FIRM>` team-member names (`<YOUR_TEAM>`)** — sourced from the user; if you don't have it, ask before processing a transcript-heavy run. Treat a quote as internal context if the speaker is internal; treat it as founder voice only if it's the founder.

If a founder update from this week conflicts with a quarterly form submitted three weeks ago, the founder update wins.

**Deal score is never a source.** Per core change #1, do not consult `dealScore` (cached at Step 1) when weighing evidence for the call. Treat it as opaque scaffolding.

**Subjective opinion from the GP is not a primary source either.** `<LEAD_PARTNER>` was explicit: "I don't want to taint the data with subjective opinion." When the GP surfaces a sentiment mismatch ("I think this one is dying, not low"), do **not** silently change the call to match — instead, surface the mismatch as a cross-portfolio flag for the user to confirm in Kruncher (e.g., "`<Company X>` returned LOW; user sentiment is closer to WRITE-OFF — confirm in `growingScoreOutcome`"). The call must still be defensible from the data on file.

### Step 8: Pitfalls to handle explicitly

- **Mid-reanalysis (`analysisStatus: "progress"`, `analysisDetail: null`).** ~10% of in-scope companies can return this on any given run because their report is being refreshed at request time. The response payload is small (~2KB) and returns **inline**, not auto-saved to disk. Handle gracefully: fall back to `projectDetail.companySummary` + cached `list_projects` data; mark the row with a `status_flag` like "Kruncher reanalysis in progress" and surface in the cross-portfolio flags section.
- **`projectDetail.employeeCount` vs `entities.numberOfEmployees`.** These can differ by an order of magnitude (e.g. 1657 vs 219). The first is LinkedIn-tracked total (often inflated for parent orgs or stale crawls); the second is Kruncher's analyst-curated figure. **Prefer `entities.numberOfEmployees`.** If both are present and diverge >20%, surface both with provenance.
- **Same-day re-runs.** When `whatsnew.previousAnalysisDate == whatsnew.currentAnalysisDate`, Kruncher carries forward the prior growth assessment with `growingScore: 0`. Do NOT interpret 0 as flat performance — note "same-day re-run, score carried forward" in Bullet 4 if relevant.
- **Stale `financialSnapshotOneSentence`** — see Step 6 above.
- **Empty `investments` + `totalInvestment: $0`.** Doesn't mean `<YOUR_FIRM>` didn't invest. Fall back to `history`, `investors`, `roundOneSentence`, `investmentNeeds`.
- **Empty `redflags`/`warnings` arrays.** Same — risk lives in entity values and fit dimensions.
- **Mis-classified fit dimensions.** Call out clearly wrong deal-breakers (e.g., "Capital Intensive Business" on an API-SaaS business) rather than reciting them.
- **Duplicate project records, pivot lineage, and purchased-domain aliasing** — see Step 2.
- **Missing website / "URL as shown in the company info"** — there is a known Kruncher bug where the website field is sometimes literally rendered as the placeholder. If the entity contains "URL as shown" or similar, drop it from the metrics line; don't surface the placeholder.
- **`projectvotesummary` defaults.** Check `hasValue` and `numberVotes > 0` before treating votes/notes as a real signal. If empty, don't weave in "internal conviction" colour.
- **Markdown in entity values** — entity text strings routinely contain `**bold**` markers AND occasionally full markdown tables (pipe-delimited rows with a `|---|---|` separator row). Examples observed: some `valuation` entities ship a multi-row valuation history table. The DOCX renderer MUST handle these (see DOCX renderer section below).
- **European-format dates in entity values** — Kruncher ingests founder-submitted forms; founders in EMEA/APAC frequently submit `15/01/2026`. Normalise via `to_us()` (Step 6) before rendering. Do not paste through.
- **"Failing but cheap" trap.** A company with < 4 months runway, no documented bridge, and a deteriorating `growingScoreOutcome` ("declining") is **not** a LOW-valuation opportunity. LOW implies upside vs. fundamentals; here the fundamentals are evaporating. Apply Step 9's call definitions strictly — these companies map to WRITE-OFF / EXITED, or HIGH if a stale mark overstates value.

### Step 9: (Optional) Log the assessment back to Kruncher

Use `save_insight` (preferred — tags entry as "AI Insight" in the activity feed) or `add_comment` to write the valuation call into the activity log. Use `generate_document` with a saved DOCX/PPTX template if a Kruncher-rendered memo is needed.

## Output

Two deliverables per run, both saved to the user's outputs folder and presented via `present_files`:

1. **DOCX (full pre-read)** — the long-form per-company narrative, grouped by fund. Use the `docx` skill.
2. **XLSX (presentation summary)** — rich multi-line memo per company in a single cell. Use the `xlsx` skill via openpyxl with `CellRichText`.

### Per-company section (DOCX)

#### Header

```
**COMPANY NAME** — <FUND_A> (or <FUND_B>) — <YOUR_FIRM> Portfolio Valuation Assessment
```

If the company has pivot ancestors (Step 2), add immediately below in italics:

```
*Previously: <PriorBrand> (<prior category>). Same team, same capital deployed.*
```

#### Metrics Line

**Format is pipe-delimited and scan-first.** GPs prefer to scan a row of `Field: value | Field: value` pairs over reading a prose sentence — the eye locks onto each field in under a second. Do NOT paste prose from `financialSnapshotOneSentence` into the metrics line; rewrite it into pipe-delimited fields.

Template:

```
**Last update on [Month DD, YYYY] (or [Month YYYY] if day is unknown):** Cash: $X (X months runway) | Burn: $X/mo | Headcount: X | [other notable: value]
```

Concrete example — preferred form (US-formatted month/year):

```
Last update on January 2026: Cash: 17 months of runway | Burn rate: significantly reduced | Headcount: 55
```

NOT acceptable (European day-first, or `dealScore` mentioned anywhere):

> As of 15/01/2026, Company X has enough cash for 17 months of runway and has significantly reduced its burn rate · Headcount: 55 · Deal Score 4/5.

Rules for building the line:

- Always lead with `Last update on [US-formatted date]:` (bold) — never bury the date inside the body of the sentence.
- Separate every fact with ` | ` (space-pipe-space). Do not use `·`, commas, or `and` as separators.
- Each field is `Label: value`. Conventional order: **Cash** → **Burn** → **Runway** (fold into Cash as `Cash: $X (X months runway)` when both are known) → **Headcount** → any other directly relevant operating fact (e.g., ARR, gross margin, contracted ARR).
- If a field is qualitative ("significantly reduced", "stable", "trending down"), keep the qualitative phrasing — do not invent a number that wasn't disclosed.
- If post-close adjustment was applied (Step 6), append ` | Post-close estimate` as its own final field rather than tucking it into the date.
- **Never include `Deal Score`, `dealScore`, or any equivalent label as a field** (core change #1).

Source priority: `financialSnapshotOneSentence` (+ dedicated `cashOnHand`, `burnRate`), `entities.numberOfEmployees` (NOT `projectDetail.employeeCount` — see Step 8). Date = most recent update that informs the figure, NOT a form deadline. All dates normalised via `to_us(...)` (Step 6).

##### Always disclose recency — including when nothing is new

The financial line **must** explicitly state when the latest update occurred. "No update" alone is not acceptable — say how stale the situation is, and surface the prior reference point.

| Situation | Required wording |
|---|---|
| Founder pushed an update within the last 30 days | `Last update on [Month YYYY]: …` |
| Latest material update is 1–6 months old | `Last update on [Month YYYY] (~X months ago): …` |
| No update in 6+ months but prior data exists | `No financial update in the last X months. Last update [Month YYYY]: <fields from that update>.` |
| Never received a financial update | `No financial update on file. Last analysis refresh: [Month YYYY].` |

Always carry the historical context forward — if you've seen earlier numbers in past Kruncher analyses or in `whatsnew.previousAnalysisDate` etc., surface them. A row reading `No financial update in the last 7 months. Last update January 2025: Cash: $1.2M (4 months runway) | Burn: $300K/mo | Headcount: 12` is far more useful than `No financial updates`. Months-ago math: derive from `whatsnew.currentAnalysisDate` (or last founder-update timestamp) vs. today's date.

#### Bullet 1 — Revenue Multiple & Comps

Calculate implied revenue multiple: post-money ÷ revenue run rate. Compare to at least two reference points (one mature public incumbent, one growth-stage analogue). State whether the multiple is justified given growth rate and stage.

Pull comp context from `competitor_insightComparableCompanies`, `competitor_insightComparableSummary`, `majorCompetitors`. If `votesAndNotes` is populated, weave in any internal exit-multiple discussion.

Format: `~Xx on $XM revenue. [Comp context and judgement.]`

If pre-revenue, say so explicitly and pivot to an engagement-multiple or stage-comp frame.

**Deal score must not appear in this bullet.** The judgement comes from revenue multiple, comp context, and growth rate — never from `dealScore`.

#### Bullet 2 — Strongest Commercial Signal

Lead with the single most compelling traction proof point: signed contract, production milestone, revenue achievement. Tie to a structural demand driver (regulation, market shift, platform lock-in) that makes the revenue durable.

Pull from `keyWinsOneSentence`, `contracts`, `partnerships`, `whatsnew.text`, `narrativeSummaryOneSentence`, `metrics`. Prioritise concrete figures, counterparty names, timelines.

#### Bullet 3 — Pipeline & Valuation Coverage

Total signed contracts + high-intent pipeline from `salesPipelineOneSentence` and `contracts`. Express as a ratio to post-money valuation. State whether pipeline supports the current post-money.

When post-money is concealed (`valuation` says "concealed" or "estimated only"), say so and reason from disclosed contracted ARR/GMV alone.

#### Bullet 4 — Risks & Kruncher Flags

Pull from:

- `projectcompanyfitdimensions`: any dimension scoring ≤2/5 or any `outcome` containing "Deal Breaker"
- `riskFlagOneSentence`, `strategicRisksOneSentence`, `operationalRisksOneSentence`
- `growingScoreExplanation` (cached from `list_projects`)
- `setbacksOneSentence`
- `employeesGrowth`, `keyHiresDeparturesOneSentence`, `jobOpeningsSummary`
- `webTrafficGrowth` if materially negative

Apply Step 6 (recency) before flagging anything as a risk — a stale runway warning is not a real risk if a post-period raise is documented in `whatsnew`. Apply Step 8 same-day re-run handling.

Be direct. Don't soften real risks. **Do not** reach for `dealScore` to soften or escalate a risk read — it is not part of the v5 reasoning chain.

#### Bullet 5 — Valuation Call + 20–30 Word Rationale (highlight this)

This is the actionable bullet — render it visually distinct in the DOCX (paragraph shaded with `<w:shd>` matching the call colour; bold header line).

**Call definition (sharpened in v5).** Frame the question as `<LEAD_PARTNER>` does:

> *"If this company were to raise or be purchased today, would the call be HIGH, FAIR, or LOW?"*

This is **not** the same question as "is the company red/yellow/green." A company can be near-failing and still warrant FAIR or HIGH (the mark is too generous relative to dwindling fundamentals); a healthy company can be LOW if traction has run ahead of the last round.

Calls:

- **HIGH** — current mark *overstates* value given today's traction, comps, or runway. A near-out-of-business company with a stale high mark is HIGH, not LOW.
- **FAIR** — reasonable for stage, growth rate, market position. The why on FAIR is what `<LEAD_PARTNER>` uses most — make it specific.
- **LOW** — current mark *understates* value given demonstrated performance, comp pricing, or pipeline. Only use when fundamentals are intact and growing.
- **WRITE-OFF** — runway < 4 months with no bridge, or `growingScoreOutcome == "companyClosed"`, or unresolvable funding gap.
- **EXITED** — `growingScoreOutcome == "companyAcquired"` and the acquisition is real (not a purchased-domain alias per Step 2).

Compound ratings are encouraged when warranted ("FAIR, trending HIGH").

**Rationale (the 20–30 word "why").** After the call, write a single sentence — 20 to 30 words, never fewer than 15, never more than 35 — explaining which data point or comp drove the call. Reference an actual number (revenue multiple, contracted ARR, runway, ownership %, comp price) — generic phrases like "given the market" or "based on traction" are not acceptable.

Reference examples (target bar):

- "Likely undervalued at FinTech growth-stage multiples; a $75–100M+ post-money at Series A close could be reasonable given pipeline and retention curve." (≈25 words)
- "Aggressive: $1B for pre-commercial space tech is venture-optimism pricing. If the physics works and they secure government contracts it could justify, but binary." (≈25 words)
- "At risk of being overvalued: $12.5M with zero revenue, six co-founders, historically brutal category. Need user engagement and retention data from beta to validate." (≈25 words)

Format (visible in the rendered paragraph):

```
VALUATION CALL: FAIR — <20–30 word rationale ending in a period>
```

Do not start the rationale with "Because". Lead with the call-direction verb (Likely, Aggressive, At risk, Defensible, Supported, Concealed, etc.) or the operative number.

**Anti-patterns to avoid in the rationale:**

- Citing `dealScore` (forbidden — core change #1).
- Paraphrasing the bullet 4 risks without tying them to *price*.
- Hedging ("possibly", "might be", "could be either way") — pick a side. Compound calls are fine, hedged rationales are not.
- Restating the call in different words ("FAIR — this valuation seems fair").

Synthesise: revenue multiple (Bullet 1), pipeline coverage (Bullet 3), growth trajectory, key risks (Bullet 4). Reference entry valuation vs current post-money where available. Pull deployed capital and ownership from `investors` / `investmentNeeds` text rather than the (typically empty) `investments` array.

### DOCX renderer — markdown handling (mandatory)

Kruncher entity strings contain markdown that **must** be converted, not dumped as raw text:

1. **`**bold**` markers** — replace with proper Word bold runs. Split on `**`, alternate the `bold` flag on each segment, add a run per segment.

2. **Pipe-delimited markdown tables** — detect blocks shaped like `| col | col |\n|---|---|\n| row | row |` and render them as native Word tables with the same blue header treatment as the fund summary tables. **Regex caveat:** use `[^\S\n]` (in-line whitespace, no newlines) for the gaps between cells and rows. A naive `\s*` greedily eats newlines into the body group and only captures one body row. Validated regex:

```python
TABLE_RE = re.compile(
    r'(?:^|\n)('
    r'\|[^\n]*\|[^\S\n]*'
    r'\n[^\S\n]*\|[\s\-:|]+\|[^\S\n]*'
    r'(?:\n[^\S\n]*\|[^\n]*\|[^\S\n]*)+'
    r')',
    re.MULTILINE
)
```

Split the bullet's text into `("text", str)` / `("table", rows)` segments; emit the lead-in into the current paragraph, the table inline, and trailing text into a fresh paragraph. Strip markdown tables out of fund-level summary-table cells (they don't belong inside a nested cell).

3. **`•` glyph in entity values** — Kruncher contracts/partnerships fields often use literal bullet glyphs. Leave them; they read fine inline.

### Summary table (DOCX, end of each fund section)

Columns:

```
| Company | Stage | Round | Growing | Call | Why | Summary |
```

- **Deal Score column is removed.** Per core change #1, deal score does not appear anywhere in the summary table.
- **Round cell** doubles as the valuation marker: `<round word>\n<valuation string>` (if available).
- **Call cell** shaded by call colour.
- **Why cell** holds the 20–30 word rationale from Bullet 5. Wrap to fit. Strip `**` and any embedded markdown tables out of this cell. **Required for every row; never blank.**
- **Summary cell:** ~25 words / ~180 chars, distinct from the Why cell — this captures the single most-load-bearing operating fact (e.g., "Closed $4M Series A in Apr 2026; ARR $1.8M, growing 12% MoM"). **Must not be truncated with `...` or `…` (Unicode ellipsis).** If the content overflows, rewrite shorter at sentence boundaries — never chop mid-clause and trail a `…`. Strip `**` and any embedded markdown tables out of this cell.

### Cross-portfolio flags (DOCX, end of doc)

After both fund sections, a "Cross-portfolio flags requiring partner discussion" section surfacing:

- **Kruncher reanalysis in progress** — list the companies where `analysisStatus == "progress"` (writeup limited to scope metadata).
- **Duplicate project records to merge** — list `hasDuplicates=True` names.
- **Short-runway watch list** — runway under 6 months without a documented bridge. These are the candidates for WRITE-OFF, not LOW (see Bullet 5 call definitions).
- **Declining growing-score** — `growingScoreOutcome == "declining"`.
- **Closed / acquired** — `growingScoreOutcome` in (`companyClosed`, `companyAcquired`) — confirm exit treatment AND check for purchased-domain aliasing (Step 2).
- **Pivot-lineage check** — note misses against the lineage map; ask user to confirm new pivots.
- **Stage hygiene** — companies in fund lists but not in `<PORTFOLIO_STAGE>`; also any known-drifting fund-list cases from the seed map.
- **Sentiment mismatch** — calls where the data-driven output diverges sharply from GP sentiment as captured in notes (Step 7). Surface, don't auto-correct.
- **Upcoming valuation events** that will mechanically re-price internal marks.

### XLSX deliverable — rich-text memo format

**This is a substantive change vs v1/v2.** The XLSX is no longer a thin ≤100-char summary; it's a full multi-section memo cell per company, formatted with `openpyxl.cell.rich_text.CellRichText`. Sheets: `<FUND_A>`, `<FUND_B>`, `Combined`, `Call Summary`.

Per-company columns (Combined adds `Fund`): `Company | Stage | Growing | Call | Memo`. **No `Deal Score` column.**

**Memo cell structure** (in order, each on its own line, blank line before call):

```
Metrics: Last update on <Month YYYY>: Cash: $X (X months runway) | Burn: $X/mo | Headcount: X
   (or, when stale: "No financial update in the last X months. Last update <Month YYYY>: <fields>")
• Capital: <round> · Raised <fundsRaised> · <valuation>
• Wins: <keyWinsOneSentence / narrativeSummaryOneSentence>
• Pipeline: <salesPipelineOneSentence> Contracts: <contracts (truncated)>
• Product/Model: <businessOneSentence> · Moat: <competitiveAdvantage> · Revenue: <revenueStreams>
• Team: <numberOfEmployees> · growth <employeesGrowth> · <keyHiresDeparturesOneSentence>
• Customers: <customersOneWord / customersNumber>
• Risks: <riskFlagOneSentence>
• Next round / ask: <investmentNeeds>

VALUATION CALL: <call> — <20-30 word rationale>
```

All dates in the memo run through `to_us(...)` first.

**Rich-text styling:**

- Section labels (`Metrics:`, `• Capital:`, …, `• Next round / ask:`) — bold, navy (`#1F3A5F`).
- `VALUATION CALL: <call>` — bold, dark red (`#9C0F0F`), size 11. The trailing rationale is regular weight.
- Body text — default weight.
- Use `openpyxl.cell.rich_text.CellRichText`, `TextBlock`, `InlineFont` (openpyxl ≥ 3.1).

```python
from openpyxl.cell.rich_text import CellRichText, TextBlock
from openpyxl.cell.text import InlineFont
LABEL = InlineFont(b=True, color="1F3A5F")
CALL  = InlineFont(b=True, color="9C0F0F", sz=11)
BODY  = InlineFont(b=False)

parts = [TextBlock(LABEL, "Metrics: "), TextBlock(BODY, metrics_line)]
# ...append "\n", then more (label, body) pairs...
parts += ["\n\n", TextBlock(CALL, f"VALUATION CALL: {call}"), TextBlock(BODY, f" — {rationale}")]
cell.value = CellRichText(parts)
```

**Layout:**

- Freeze top row.
- Wrap text on every memo cell; vertical-align top; left-align text.
- Memo column width: 120 chars. Other columns sized for content (Company 20, Fund 7, Stage 12, Growing 11, Call 16).
- Row height: auto-size from memo length using `15 * line_count + 8`, capped at 600 pt. Lines = explicit newlines + wrap overflow at 110 chars/line.
- Call cell: colour-fill matching the call (same palette as DOCX), centred, bold.

**Call colour palette** (DOCX paragraph shading + XLSX cell fill):

```
WRITE-OFF       #999999
EXITED          #B0B0B0
HIGH            #F4CCCC   (red)
FAIR↑ HIGH      #FCE5CD   (light orange)
FAIR            #FFF2CC   (light yellow)
FAIR↓ LOW       #D0E0E3   (light cyan)
LOW             #D9EAD3   (green)
```

### Cleaning helpers for cell contents

Strip markdown tables and bold markers out of cell text before insertion (markdown belongs only inside the DOCX bullet flow, not inside table cells or memo lines):

```python
def clean(s):
    s = TABLE_RE.sub('', s)         # drop pipe tables
    s = s.replace("**", "")         # drop bold markers
    s = re.sub(r'[ \t]+', ' ', s)
    s = re.sub(r'\n{3,}', '\n', s)
    return s.strip()
```

### Rationale length helper

Enforce the 20–30 word target on every rationale before it lands in any deliverable:

```python
def enforce_rationale_length(text: str, target_min=20, target_max=30, hard_max=35) -> str:
    """Trim or pad rationale to fit the 20-30 word window.
    Never returns ellipsis; cuts at sentence boundary when over hard_max."""
    words = text.strip().split()
    if len(words) <= hard_max:
        return text.strip()
    # Over hard_max: trim to <= target_max words, ending at sentence boundary if possible.
    trimmed = " ".join(words[:target_max])
    for boundary in (". ", "; ", ": "):
        idx = trimmed.rfind(boundary)
        if idx > len(trimmed) // 2:
            return trimmed[:idx + 1]
    return trimmed.rstrip(",;:") + "."
```

If the rationale comes back under `target_min` words, ask the model for a fuller one rather than padding with filler.

## Tone

Direct, analytical, no hedging. Investment memo, not summary report. Dollar amounts and percentages, not adjectives. Every bullet contains a judgement, not just data. Every valuation call carries a 20–30 word rationale grounded in a specific number or comp.

## No `…` truncation — anywhere in any deliverable

**Never append `…` (Unicode ellipsis) or `...` to clip an entity value, sub-section, table cell, summary, or memo line.** A clipped string trails an ellipsis where a sentence should have ended; the reader is left wondering what was lost, and the deliverable looks unfinished.

Apply this rule to:

- DOCX per-company bullets (Bullets 1–5 of the per-company section, including the 20–30 word rationale)
- DOCX fund summary-table cells (Company / Stage / Round / Growing / Call / Why / Summary)
- DOCX cross-portfolio flags section
- XLSX memo cell (`Metrics: …`, `• Wins: …`, etc.)
- XLSX `Call Summary` sheet examples column
- Any cleaning helper applied to entity text

Permitted alternatives when source text is genuinely too long for the slot:

1. **Cut at the last sentence boundary** that fits (`.`, `;`, `:`, or `—`) — drop everything after that boundary, no ellipsis.
2. **Rewrite the field analytically** — replace the long quoted entity with one tight clause that captures the judgement (this is preferred for the summary cell).
3. **Drop low-signal trailing sub-fields** — if a bullet would otherwise need clipping, omit the least-load-bearing sub-section (e.g., `Operational risks` sub-clause if `Strategic risks` already captures the headline).

Bullets in the DOCX per-company section have **no hard character cap** — DOCX paragraphs flow at any length. Cap only where there is a real width constraint (table cells, the XLSX memo column). Even there, cap by **sentence**, not by character count.

Helper pattern to enforce:

```python
def cap_at_sentence_boundary(s: str, max_chars: int) -> str:
    """Return s if it fits, else trim to the last sentence-ending punctuation at or before max_chars.
    Never returns an ellipsis. If no boundary exists within max_chars, fall back to the last space."""
    s = s.strip()
    if len(s) <= max_chars: return s
    candidate = s[:max_chars]
    # Prefer the last sentence-ending punctuation we can find
    for boundary in ('. ', '; ', ' — ', ': '):
        idx = candidate.rfind(boundary)
        if idx > max_chars // 2:
            return candidate[:idx + len(boundary)].rstrip()
    # Last resort: cut at the last space before the limit (no ellipsis)
    sp = candidate.rfind(' ')
    return (candidate[:sp] if sp > 0 else candidate).rstrip()
```

If a cleaning helper currently appends `…`, remove that branch.

## Batch entry point: `get_portfolio_update`

The named entry for a portfolio-wide refresh. Implementation:

1. `get_filters` → resolve `<PORTFOLIO_STAGE>` UUID AND the `<FUND_A>` / `<FUND_B>` list UUIDs (Step 0).
2. `list_projects(projectStageId=...)` with pagination → cache scores, list-membership, and metadata per project (Step 1). Cache `dealScore` for hygiene-only; never surface.
3. Filter to `<FUND_A>` ∪ `<FUND_B>` by `projectlists[].id` (Step 0 mechanism). Drop `<EXCLUDED_FUND_1>`, `<EXCLUDED_FUND_2>`, and anything unassigned.
4. Dedupe and apply pivot-lineage map AND purchased-domain-alias check (Step 2).
5. For each canonical company, parallel `get_report` in batches of 8 (Step 3, v5 section bundle — no `dealscore`). Parse from disk + handle inline-returned "in progress" responses (Step 4 + Step 8).
6. Apply Steps 5-8 (entity extraction, recency + US date normalisation, source weighting, pitfalls including the "failing-but-cheap" trap).
7. Build per-company writeups with markdown-aware DOCX renderer, including the 20–30 word rationale on every call. Save DOCX.
8. Build rich-text XLSX memo with bold section labels and per-row colour fill on the Call cell, with the 20–30 word rationale appended to the `VALUATION CALL:` line. Save XLSX.
9. Present both files via `present_files`.

## Tool Reference (Kruncher MCP)

- `get_filters` — pipeline stages with UUIDs + counts, AND project lists with counts. Always called first.
- `list_projects` — filtered listing. Authoritative source for `growingScore*` / `projectlists[]` per project. (Returns `dealScore` too — cache for hygiene only, do not render.) **Does not yet accept a list-membership filter** — see Step 0 workaround.
- `search_companies` — keyword name/industry search; used for ad-hoc lookups.
- `get_report` / `get_report_sections` — analysis content. Expect responses to spill to disk for any company with a full analysis; expect a small inline response when reanalysis is in progress. **Do not include `dealscore` in the requested section bundle.**
- `save_insight`, `add_comment` — write the assessment to the activity log.
- `generate_document` — Kruncher-side DOCX/PPTX render if a saved template is available.

`get_portfolio_update` is the named batch entry used by this skill; today implemented as the sequence above.

## Changelog

- **v5** — Four changes driven directly by `<LEAD_PARTNER>`'s review of the v4 output:
  1. **Deal score excluded from analysis end-to-end.** Removed `dealscore` from the section request bundle, dropped the column from the DOCX summary table and XLSX memo, prohibited references in every bullet, and excluded it from dedupe tie-breaking and risk-weighting. `dealScore` is still cached from `list_projects` for hygiene-drift detection only.
  2. **20–30 word rationale required on every valuation call.** Bullet 5, the new `Why` column in the summary table, and the `VALUATION CALL:` line in the XLSX memo each carry a 20–30 word (hard max 35) sentence explaining the call. New `enforce_rationale_length` helper.
  3. **US date format mandatory across all deliverables.** Added `to_us(...)` helper covering `Month DD, YYYY`, `Mon DD, YYYY`, `MM-DD-YYYY`, and `Month YYYY`. Every Kruncher-sourced date — including European `DD/MM/YYYY` and ISO `YYYY-MM-DD` — is normalised before rendering. No ISO, no `DD-MM-YYYY`.
  4. **Valuation-call definition sharpened.** "If this company were to raise or be purchased today, would the call be HIGH/FAIR/LOW?" — distinct from a company-health red/yellow/green read. New "failing-but-cheap" pitfall in Step 8: companies under 4-month runway map to WRITE-OFF/HIGH, never LOW. Added purchased-domain aliasing to Step 2. Added sentiment-mismatch hygiene flag in the cross-portfolio section. Documented stage-hygiene seed map for known fund-list drift cases.

- **v4** — Carried forward: pipe-delimited metrics line, mandatory recency disclosure (including stale-row wording), `…` truncation banned across all deliverables, `cap_at_sentence_boundary` helper, full markdown-aware DOCX renderer, rich-text XLSX memo cells with colour-filled Call cells.
- **v3.2** — Three quality-of-output improvements after the first full-cohort run: (a) metrics line pipe-delimited; (b) recency mandatory with stale-row wording; (c) `…` truncation banned.
- **v3.1** — Validated against a full `<PORTFOLIO_STAGE>` run. `projectlists[]` canonical fund signal; mid-reanalysis handling; prefer `entities.numberOfEmployees`; row-level value lookups before `analysisreportrows[]`; DOCX must convert markdown bold and pipe-tables; rich-text XLSX memo replaces single-line summary; summary column "Round" carries valuation as a second line.
