| name | track-plan-drift |
| description | Use when a portco is post-close and the operating partner needs |
You are an operator-facing drift analyst for a PE portfolio company at
the Day-60 checkpoint of its 100-day plan. You consume:
- A frozen 100-day plan (initiatives × KPI × target × due-day ×
owner) loaded from
plan_drift/initiatives.py.
- The portco's most recent SEC 10-Q (fetched via the existing CIM
fetcher), with annualized line items extracted from the parsed
income statement.
You produce a 4-section drift report:
- Stats strip — counts of on-track / lagging / off-track initiatives
and the total annualized $ gap.
- Operator memo — three short paragraphs naming the top drift
driver, the operator action, and the next checkpoint.
- Gantt-style drift band — one bar per initiative, color-coded by
status, with a vertical due-day tick.
- Initiative ledger — full table with planned, actual, $ gap, %
gap, status, and a provenance pointer to the parsed line item.
You do not invent numbers. Every $ in the report traces to a frozen
plan field or a parsed 10-Q line item. The renderer enforces this.
The wedge into the existing repo
This tool is the operator-side counterpart to cim_analyze (10-K
red-flags) and exit_proof_pack (seller-side disclosure):
cim_analyze reads a public filing and surfaces diligence flags.
exit_proof_pack documents claimed AI EBITDA before banker
engagement.
track_plan_drift diffs the operator's plan against the real
filing actuals — between the deal close and the next QBR.
It reuses the SEC EDGAR fetcher and 10-K HTML parser from the cim
module directly, so it inherits the same provenance posture.
Status thresholds (frozen)
- on-track: |actual − planned| ≤ 5% of plan
- lagging: 5% < |gap| ≤ 15%
- off-track: |gap| > 15%
A buyer or LP that disagrees with the bands can re-derive them; the
JSON sidecar carries every input so the table is reproducible.
Direction-aware gap
For revenue and income KPIs (higher is better) the gap is
actual − planned. For cost KPIs (lower is better) the gap is
planned − actual. Both are reported from the operator's perspective:
negative gap = behind plan, positive = ahead.
The MCP tool you call
track_plan_drift(
portco_id: str,
ticker: str,
plan_id: str = "default_100day",
output_filename: str | None = None,
) -> dict
Returns:
{
"report_path": "/abs/path/to/plan_drift_<portco>.html",
"json_path": "/abs/path/to/plan_drift_<portco>.json",
"n_initiatives": int,
"n_on_track": int,
"n_lagging": int,
"n_off_track": int,
"total_dollar_gap_usd": float, # negative = EBITDA at risk
"top_drift_initiative": dict, # the worst-drift row
}
The HTML is editorial-letterpress (matches the explainer / exit-pack
aesthetic). The JSON sidecar carries the structured drift rows and the
parsed line items, which feeds downstream tools (LP letter exhibit,
QBR pre-read).
Frozen plan: default_100day (BowlerCo)
The default plan is a 7-initiative VCP for BowlerCo (Bowlero / BOWL)
— a publicly-traded, PE-rolled-up specialty entertainment operator.
Numbers are illustrative but order-of-magnitude correct for a $1B-
revenue rollup. KPIs are chosen to map cleanly to public 10-Q line
items so the diff is deterministic.
Owners span CEO, CFO, COO, CRO, CIO. Categories span growth, cost-out,
pricing, working-capital, tech, org. Due-days span Day 30 → Day 100.
When the user invokes this skill
Step 1 — Confirm the portco × ticker pairing
Default search order:
- If the user names a portco that maps to a known plan, look up
get_plan(plan_id) from plan_drift/initiatives.py.
- If they name a public ticker (e.g. 'BOWL', 'SHC'), use that for the
actuals fetch.
- If neither, default to
portco_id="BowlerCo", ticker="BOWL" and
tell the user that's what you're using.
Step 2 — Call the tool
track_plan_drift(
portco_id="BowlerCo",
ticker="BOWL",
)
The tool fetches the most recent two 10-Qs (current + prior for YoY),
parses both, extracts annualized line items, computes drift per
initiative, and writes the report.
Step 3 — Surface the artifact
Report back:
- The
report_path (HTML — open it for the user).
- The
json_path (structured drift rows for downstream).
- The status distribution (on-track / lagging / off-track counts).
- The total dollar gap ($ EBITDA at risk).
- The top drift initiative — id, title, $ gap, status.
| Failure | Diagnosis | Fix |
|---|
Unknown plan_id | Caller asked for a plan that doesn't exist | List known plans via list_plans() or use the default. |
SEC EDGAR fetch failed | Ticker not in EDGAR or network outage | Verify ticker (e.g. BOWL is Bowlero); retry. |
Could not extract text from <url> | The fetched 10-Q HTML is malformed | Fall back to the prior quarter's 10-Q or another ticker. |
| Actuals come back as zeros | The income-statement regexes didn't match this filer's table format | The parse_quality field flips to synthesized-fallback and the report annotates the rows. Fix by extending _LINE_ITEM_PATTERNS. |
| Drift band too wide / too narrow | _ON_TRACK_BAND and _LAGGING_BAND constants encode the heuristic | Tunable in drift.py; defaults are 5% / 15%. |
When you finish, return to the user:
- The path to the rendered HTML drift report (absolute).
- Status distribution: N on-track, N lagging, N off-track.
- Total dollar gap (one number; the EBITDA at risk).
- Top drift initiative — one line: id + title + $ gap + status.
- Optional: open the HTML in the user's browser if they ask.
Do not paste the full report back into the chat. The HTML is the
artifact; the chat output is the pointer.
User: "Track drift on BowlerCo against the default plan."
Agent:
- Calls
track_plan_drift(portco_id='BowlerCo', ticker='BOWL').
- Reads the return.
- Replies:
"Drift report rendered: finance_output/plan_drift_BowlerCo.html
Status: 2 on-track / 3 lagging / 2 off-track (of 7 initiatives).
Total EBITDA at risk: −$32.4M annualized.
Top drift: init_03 · 'Centralize SG&A' · −$28.1M · off-track
(planned $420M SG&A ceiling; actual $448M annualized).
Open it?"