# Advisor & Recommendations Implementation Plan

> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.

**Goal:** Add a deterministic advisory layer to firma — a `@firma/advisor`
use-case package that turns the assembled portfolio/macro/cash-flow
context into a weekly `Stance`, ranked `Recommendation[]` moves, and a
`WatchlistItem[]` discovery surface, rendered identically through
`firma brief` (CLI) and `get_brief` (MCP).

**Architecture:** A new pure use-case package `@firma/advisor` depends on
`@firma/domain` only. `@firma/brief` stays the combining layer: it
gathers context (existing `BriefData` fields plus a new cash-flow
context) and calls `analyze(context) → { stance, moves, watchlist }`.
Recommendations are computed deterministically in code; the model/CLI
only renders them. Risk recommendations anchor on *scenario
consequence* (forced-seller risk, goal-feasibility breach), not on a
scalar risk verdict; `risk_tolerance` is a severity dial. Growth
recommendations anchor on *opportunity cost*.

**Tech Stack:** TypeScript, Turborepo + Yarn Berry, tsup, Vitest;
Drizzle + better-sqlite3 (`@firma/db`); Commander + Clack (CLI);
`@modelcontextprotocol/sdk` + zod (MCP).

**Design doc:** `docs/plans/2026-05-17-advisor-recommendations-design.md`
**Branch:** `feat/advisor-recommendations` (off `main`).
**Build order:** `@firma/advisor` → `@firma/brief` → `@firma/finnhub` →
`@firma/mcp` → `@evan-moon/firma`.
**Conventions:** no explanatory comments in code; no redundant type
annotations (annotate only export-boundary contracts); investment
amounts in 만원/억원, never "M"; the CLI/MCP parity harness
(`test/parity.test.ts`) stays green at every commit.

**Phasing:** Phase 1 (Tasks 1–11) ships Stance + Moves end-to-end — the
shippable milestone. Phase 2 (Tasks 12–14) adds the Watchlist, which
needs new Finnhub peers plumbing. Each task ends green and committed.

---

## Phase 1 — Stance + Moves

### Task 1: Scaffold `@firma/advisor` with types and an empty `analyze`

**Files:**
- Create: `packages/use-case/advisor/package.json`, `tsconfig.json`,
  `tsup.config.ts`, `src/index.ts`, `src/types.ts`, `src/analyze.ts`,
  `src/analyze.test.ts`
- Modify: root `tsconfig.json` references / `turbo.json` if packages are
  enumerated; `yarn` workspace picks it up via `packages/*`.

Mirror `packages/use-case/brief/package.json` exactly (name
`@firma/advisor`, same `exports`, `build` script, `tsup`/`typescript`
devDeps, `@firma/domain` devDep). No runtime deps beyond `es-toolkit` if
needed.

**Step 1: Write the failing test** — `src/analyze.test.ts`:

```ts
import { describe, expect, it } from 'vitest';
import { analyze } from './analyze.js';
import type { AdvisorContext } from './types.js';

const emptyContext: AdvisorContext = {
  /* minimal: zero holdings, null macro/signals/goal, empty cashflow */
};

describe('analyze', () => {
  it('returns empty moves and a defensible stance for an empty context', () => {
    const result = analyze(emptyContext);
    expect(result.moves).toEqual([]);
    expect(result.watchlist).toEqual([]);
    expect(result.stance.bias).toBeDefined();
  });
});
```

**Step 2:** Run `npx vitest run packages/use-case/advisor` — FAIL
(module not found).

**Step 3: Define `src/types.ts`** — the export-boundary contracts:

```ts
export type RecommendationType =
  | 'deploy' | 'contribution' | 'tactical'
  | 'derisk' | 'runway' | 'rebalance';

export type Severity = 'critical' | 'warning' | 'info';

export type DrillDown = { tool: string; args?: Record<string, unknown> };

export type Recommendation = {
  type: RecommendationType;
  severity: Severity;
  headline: string;
  rationale: string;
  action: string;
  metrics: Record<string, number>;
  drill_down: DrillDown | null;
};

export type StanceBias =
  | 'aggressive' | 'mixed' | 'defensive' | 'contrarian_aggressive';

export type Stance = {
  bias: StanceBias;
  market_rationale: string;
  personal_rationale: string;
  inputs: {
    regime: string | null;
    stress: number | null;
    runway_months: number | null;
    goal_pace: 'ahead' | 'behind' | 'on_track' | 'unknown';
    idle_cash_usd: number;
  };
};

export type WatchlistItem = {
  ticker: string;
  market: string;
  relation: 'peer' | 'sector_mover' | 'news_linked';
  context: string;
  drill_down: DrillDown;
};

export type CashContext = {
  idle_cash_usd: number;
  idle_months: number | null;
  monthly_surplus_usd: number | null;
  monthly_expense_usd: number | null;
  runway_months: number | null;
};

export type AdvisorContext = {
  home_currency: string;
  portfolio: { total_value_usd: number; holdings_count: number };
  holdings: AdvisorHolding[];
  concentration: BriefConcentration | null;
  signals: { stress: StressIndex; regime: Regime } | null;
  goal_tracking: BriefGoalTracking | null;
  risk_summary: BriefRiskSummary | null;
  cashflow: CashContext;
  risk_tolerance: 'conservative' | 'moderate' | 'aggressive' | null;
};

export type AdvisorResult = {
  stance: Stance;
  moves: Recommendation[];
  watchlist: WatchlistItem[];
};
```

`AdvisorHolding`, `BriefConcentration`, `BriefGoalTracking`,
`BriefRiskSummary` re-use the shapes from `@firma/brief`'s `types.ts` —
import the brief types or copy the minimal field subset the rules need
(decide in implementation; prefer importing if `@firma/brief` does not
depend on `@firma/advisor`, which it must not — keep the dependency
one-directional `brief → advisor`, so copy the subset into advisor).

**Step 4: `src/analyze.ts`** — empty implementation returning
`{ stance: <neutral>, moves: [], watchlist: [] }`. `src/index.ts`
re-exports `analyze` and all types.

**Step 5:** Run the test — PASS. Run `yarn typecheck` and `yarn build`
— advisor compiles.

**Step 6: Commit** — `feat(advisor): scaffold @firma/advisor package and types`

---

### Task 2: Derive cash-flow context from balance + flow entries

**Files:**
- Create: `packages/use-case/advisor/src/cashflow.ts`,
  `src/cashflow.test.ts`

**Context:** `balance_entries` and `flow_entries`
(`packages/shared/db/src/schema.ts:17-47`) store `period`, `date`,
`type`, `sub_type`, `category`, `amount` (integer, native currency),
`currency` (default KRW). Balance = stocks of wealth at a period; flow =
income/expense for a period. They are NOT in USD — convert to USD via
the FX cache.

**Step 0 — investigate conventions:** Before writing code, inspect the
live DB to learn the `type` / `sub_type` / `category` vocabulary:
`sqlite3 ~/.firma/firma.db "SELECT DISTINCT type, sub_type, category FROM balance_entries"` and the same for `flow_entries`.
Record which values denote **cash / deposits** (idle, deployable) versus
invested assets, and which flow values denote income vs expense. Build a
deterministic classifier from the actual vocabulary; default unknown
categories conservatively (not counted as idle cash).

**Step 1: Write failing tests** — `deriveCashContext(balanceEntries, flowEntries, fxRates, homeCurrency)`:
- idle cash = sum of latest-period balance entries classified as cash,
  converted to USD.
- `monthly_expense_usd` = mean expense flow over the last N periods.
- `monthly_surplus_usd` = mean (income − expense).
- `runway_months` = `idle_cash_usd / monthly_expense_usd`.
- `idle_months` = consecutive most-recent periods where cash balance
  did not decline (a proxy for "sitting undeployed").
- Empty entries → all-null `CashContext` with `idle_cash_usd: 0`.

Each as a discrete `it()` with a fixed fixture.

**Step 2:** Run — FAIL.

**Step 3:** Implement `cashflow.ts`. FX conversion uses the same
`fx_rates`-cache lookup the codebase already uses (a rate-by-date map);
accept a pre-built `Record<string, number>` rate map or `FxRate[]` so
the function stays pure.

**Step 4:** Run — PASS. `yarn typecheck`.

**Step 5: Commit** — `feat(advisor): derive cash-flow context from balance/flow entries`

---

### Task 3: Stance rule — market ∩ personal capacity

**Files:**
- Create: `packages/use-case/advisor/src/stance.ts`, `src/stance.test.ts`

**Step 1: Write failing tests** — `computeStance(context) → Stance`,
covering the matrix from the design doc:
- Risk-on regime + sufficient capacity (runway ≥ 6mo, goal ahead/on
  track, idle cash > 0) → `aggressive`.
- Risk-on + thin runway (< 3mo) → `mixed`.
- Risk-off + thin runway → `defensive`.
- Risk-off + well ahead of goal + thick runway (≥ 12mo) → `contrarian_aggressive`.
- `signals == null` (no FRED key) → stance still returns, bias derived
  from capacity alone, `market_rationale` notes macro data absent.

Define the regime/stress → market-signal mapping and the
runway/goal-pace/idle-cash → capacity mapping as small explicit helpers,
each independently tested at its boundaries.

**Step 2:** Run — FAIL.

**Step 3:** Implement `stance.ts`. `goal_pace` derives from
`goal_tracking.on_track` and the cagr gap. `market_rationale` and
`personal_rationale` are deterministic strings naming the numbers.

**Step 4:** Run — PASS. `yarn typecheck`.

**Step 5: Commit** — `feat(advisor): stance rule from market and personal capacity`

---

### Task 4: Protective rules — `derisk` and `runway`

**Files:**
- Create: `packages/use-case/advisor/src/rules/derisk.ts`,
  `src/rules/runway.ts`, and co-located `.test.ts` files.

**Step 1: Write failing tests.**

`runway(context) → Recommendation[]`:
- `runway_months < 3` → one `critical` rec; `< 6` → `warning`; `≥ 6` →
  none. `metrics` carries `runway_months`, `monthly_expense_usd`,
  `idle_cash_usd`. `drill_down` → `{ tool: 'report', args: { target: 'flow' } }`.

`derisk(context) → Recommendation[]` — scenario-based, NOT a scalar
verdict:
- Compute a plausible drawdown for the largest position from
  `risk_summary.annualized_vol_pct` (or a default haircut, e.g. −50%,
  when vol is unavailable). Recompute net worth.
- **Forced-seller test:** if the post-drawdown cash runway falls below
  the expense buffer → emit a `derisk` rec regardless of
  `risk_tolerance`.
- **Goal-breach test:** if the post-drawdown net worth makes
  `cagr_needed` exceed a feasibility ceiling (e.g. > 25%) → emit a
  `derisk` rec regardless of tolerance.
- If neither trigger fires → no rec (an aggressive, well-buffered
  portfolio is correctly left alone).
- `risk_tolerance` maps the emitted rec's `severity`: conservative →
  `warning`/`critical`, aggressive → `info`.
- `drill_down` → `{ tool: 'show_concentration' }`.

Test each trigger in isolation and the tolerance→severity mapping.

**Step 2:** Run — FAIL.

**Step 3:** Implement both rule modules as pure functions.

**Step 4:** Run — PASS. `yarn typecheck`.

**Step 5: Commit** — `feat(advisor): derisk and runway protective rules`

---

### Task 5: Growth rules — `deploy`, `contribution`, `tactical`

**Files:**
- Create: `packages/use-case/advisor/src/rules/deploy.ts`,
  `contribution.ts`, `tactical.ts` + co-located `.test.ts` files.

**Step 1: Write failing tests.**

`deploy`:
- `idle_cash_usd` above a floor AND `idle_months >= 2` → rec.
  `rationale`/`metrics` carry idle amount, idle months, and an
  annualised opportunity cost (idle cash × a reference return constant,
  e.g. the 10Y yield from context, or a fixed 4% fallback).
  `drill_down` → `{ tool: 'show_portfolio' }`.

`contribution`:
- `monthly_surplus_usd` meaningfully exceeds recent contribution flow →
  rec naming the unused monthly surplus. `drill_down` →
  `{ tool: 'report', args: { target: 'combined' } }`.

`tactical`:
- Regime Risk-on while cash weight is high → "under-exposed to upside"
  rec. Regime Risk-off → an `info` rec to hold/stay defensive.
  `drill_down` → `{ tool: 'show_macro' }`.

**Step 2:** Run — FAIL.

**Step 3:** Implement the three rule modules.

**Step 4:** Run — PASS. `yarn typecheck`.

**Step 5: Commit** — `feat(advisor): deploy, contribution, tactical growth rules`

---

### Task 6: `rebalance` rule (scenario-framed)

**Files:**
- Create: `packages/use-case/advisor/src/rules/rebalance.ts` + test.

**Step 1: Write failing tests** — `rebalance(context)`:
- Trigger off `concentration.by_ticker.top[0]`. NOT an absolute
  threshold verdict — frame as: "if the top position drops 50%, the
  goal is delayed N years / net worth falls X%". Emit only when that
  scenario impact is material (e.g. goal delay ≥ 2 years, or post-shock
  drawdown ≥ a fraction of net worth).
- `risk_tolerance` dials severity.
- `drill_down` → `{ tool: 'show_concentration' }`.
- Sector concentration (`by_sector.top[0]`) handled with the same
  scenario logic.

**Step 2:** Run — FAIL. **Step 3:** Implement. **Step 4:** PASS +
`yarn typecheck`.

**Step 5: Commit** — `feat(advisor): scenario-framed rebalance rule`

---

### Task 7: Wire `analyze` — rank moves, attach stance ordering

**Files:**
- Modify: `packages/use-case/advisor/src/analyze.ts`, `src/analyze.test.ts`

**Step 1: Write failing tests:**
- `analyze` runs all six rules, concatenates their `Recommendation[]`.
- Moves are ranked: primary key `severity` (`critical` > `warning` >
  `info`); secondary key is stance-driven — an `aggressive`/`mixed`/
  `contrarian_aggressive` stance floats growth types
  (`deploy`/`contribution`/`tactical`) above protective ones at equal
  severity; a `defensive` stance floats `derisk`/`runway` above growth.
- `watchlist` stays `[]` in Phase 1 (filled in Phase 2).
- An empty context yields `moves: []` and a coherent stance.

**Step 2:** Run — FAIL.

**Step 3:** Implement the orchestration + a `rankMoves(moves, stance)`
helper.

**Step 4:** Run — PASS. `yarn typecheck` / `yarn build`.

**Step 5: Commit** — `feat(advisor): rank moves by severity and stance`

---

### Task 8: Integrate the advisor into `@firma/brief`

**Files:**
- Modify: `packages/use-case/brief/package.json` (add `@firma/advisor`
  workspace dep), `src/assemble.ts`, `src/types.ts`, `src/index.ts`
- Modify: `BriefDeps` to also receive `balanceEntries`, `flowEntries`,
  `fxRates` (so the brief can build `CashContext`).
- Test: `packages/use-case/brief/src/assemble.test.ts` (create if absent)
  or extend the parity harness in Task 11.

**Step 1: Write failing tests** — `assembleBriefData` now returns
`BriefData` with three new fields: `stance: Stance`, `moves:
Recommendation[]`, `watchlist: WatchlistItem[]`, plus a `freshness`
block (`{ prices_synced: string | null; fx_synced: string | null }` —
derive `prices_synced` from the max `prices` row timestamp and
`fx_synced` from the max `fx_rates` date). Existing `insights` stays for
now (additive — do not break current renderers).

**Step 2:** Run — FAIL.

**Step 3:** In `assembleBriefData`, after `partial` is built, construct
the `AdvisorContext` (reuse `concentration`, `signals`, `goal_tracking`,
`risk_summary`, holdings; build `cashflow` via Task 2's
`deriveCashContext`; read `risk_tolerance` from `deps.profile`), call
`analyze`, and spread `stance`/`moves`/`watchlist`/`freshness` into the
returned `BriefData`. Add the new types to `src/types.ts` (re-export
the advisor types).

**Step 4:** Run — PASS. `yarn typecheck` / `yarn build`.

**Step 5: Commit** — `feat(brief): assemble advisor stance, moves, freshness`

---

### Task 9: MCP — render advisor output in `get_brief`, embed per-dimension recs, slim `server.instructions`

**Files:**
- Modify: `apps/mcp/src/tools/portfolio.ts` (`get_brief`),
  `apps/mcp/src/tools/macro.ts` (`show_macro`),
  `apps/mcp/src/helpers.ts` (`SERVER_INSTRUCTIONS`)
- Modify: wherever `show_concentration` lives (`portfolio.ts`).
- Test: `test/parity.test.ts`.

**Step 1: Write failing tests** — `get_brief` result includes
`stance`, `moves`, `watchlist`, `freshness`. `show_concentration`
includes the `derisk`/`rebalance` recommendations for its dimension
(the same `Recommendation` objects `analyze` produced). `show_macro`
includes the `tactical` recommendation.

**Step 2:** Run — FAIL.

**Step 3:** Surface the new fields in `get_brief`. For the `show_*`
tools, embed the dimension's recommendation: have each tool call
`analyze` (or a scoped variant) and attach the filtered moves. Set
`drill_down` to `null` on a recommendation when the tool that owns the
dimension is itself emitting it (no self-pointer). Rewrite
`SERVER_INSTRUCTIONS` to three lines: portfolio/market question →
`get_brief` first; deeper → follow `drill_down`; `freshness` stale →
`sync` first.

**Step 4:** Run — PASS. `yarn typecheck` / `yarn build` /
`npx vitest run`.

**Step 5: Commit** — `feat(mcp): surface stance, moves, freshness in get_brief`

---

### Task 10: CLI — render advisor output in `firma brief` and `firma show *`

**Files:**
- Modify: `apps/cli/src/commands/brief.ts`,
  `apps/cli/src/commands/macro.ts`, the `show concentration` command.

**Step 1: Write failing tests** — extend the CLI rendering tests (and
the parity harness in Task 11). `firma brief` prints a Stance section,
a ranked Moves section, and a freshness line; `--json` carries
`stance`/`moves`/`watchlist`/`freshness`. `firma show concentration`
prints its dimension's recommendation. `drill_down` renders as a CLI
command hint (`→ firma show concentration`), not a tool name.

**Step 2:** Run — FAIL.

**Step 3:** Implement the renderers. A shared helper maps a
`DrillDown` (`{ tool, args }`) to its CLI command string
(`show_concentration` → `firma show concentration`,
`report`+`{target:'flow'}` → `firma report flow`, etc.). A stale
`freshness` renders a `→ firma sync` hint.

**Step 4:** Run — PASS. `yarn typecheck` / `yarn build` /
`npx vitest run`.

**Step 5: Commit** — `feat(cli): render stance, moves, freshness in brief and show`

---

### Task 11: Parity tests + docs for Phase 1

**Files:**
- Modify: `test/parity.test.ts`
- Modify: `apps/docs/content/docs/brief.{en,ko}.mdx`,
  `claude.{en,ko}.mdx`; `CLAUDE.md`.

**Step 1:** Add a parity test asserting `firma brief --json` and
`get_brief` return the identical `stance` / `moves` / `watchlist` /
`freshness` payload (omit volatile `generated_at`). Assert
`show_concentration` ↔ `firma show concentration` agree on the embedded
recommendation.

**Step 2:** Run — must pass (0 failures).

**Step 3:** Update docs: `brief.mdx` documents the Stance / Moves
sections; `claude.mdx` notes `get_brief` now returns advisory output;
`CLAUDE.md` documents `@firma/advisor` under Architecture.

**Step 4:** `yarn typecheck` / `yarn build` / `npx vitest run` — all
green.

**Step 5: Commit** — `test: CLI/MCP parity for advisor output; docs`

---

## Phase 2 — Watchlist

### Task 12: Finnhub `peers` — external-api client + domain capability

**Files:**
- Modify: `packages/external-api/finnhub/src/*` (add a `companyPeers`
  raw client call to `/stock/peers`).
- Modify: `packages/domain/domain/src/market/capability.ts` (add
  `'peers'` to `MarketCapability`), `market/client.ts` (add
  `getPeers(symbol) → SymbolRef[]`), and the Finnhub adapter in
  `packages/adapter/adapters/*` to declare the capability.
- Tests: co-located, plus extend `market/client.test.ts`.

**Step 1: Write failing tests** — the Finnhub client returns peer
tickers for a symbol; the capability router exposes `getPeers`; a
provider lacking the capability throws `UnsupportedCapabilityError`.

**Step 2:** Run — FAIL. **Step 3:** Implement. **Step 4:** PASS +
`yarn typecheck` / `yarn build`.

**Step 5: Commit** — `feat(finnhub): company peers endpoint and capability`

---

### Task 13: Watchlist sourcing in `@firma/advisor`

**Files:**
- Create: `packages/use-case/advisor/src/watchlist.ts` + test.
- Modify: `src/types.ts` `AdvisorContext` to carry the raw inputs the
  watchlist needs (peers per holding, sector movers, holdings' news
  tickers); `src/analyze.ts` to populate `watchlist`.

**Step 1: Write failing tests** — `buildWatchlist(context) → WatchlistItem[]`:
- A peer of a holding that moved materially this week → `relation: 'peer'`.
- A mover in a sector the user is exposed to → `relation: 'sector_mover'`.
- A ticker recurring in holdings' news, not already held →
  `relation: 'news_linked'`.
- Dedup against existing holdings and across sources; cap the count.
- Each item's `drill_down` → `{ tool: 'show_valuation', args: { ticker } }`.

**Step 2:** Run — FAIL. **Step 3:** Implement. The brief gathers the
peers/movers/news inputs (Task 14) and passes them in `AdvisorContext`.

**Step 4:** Run — PASS. `yarn typecheck`.

**Step 5: Commit** — `feat(advisor): watchlist candidate sourcing`

---

### Task 14: Surface the Watchlist in brief (CLI + MCP) + docs

**Files:**
- Modify: `packages/use-case/brief/src/assemble.ts` (fetch peers via the
  new capability for each holding; pass peers/movers/news into
  `AdvisorContext`).
- Modify: `apps/cli/src/commands/brief.ts` (render a Watchlist section),
  `apps/mcp/src/tools/portfolio.ts` (`get_brief` already returns
  `watchlist` — verify it is now populated).
- Modify: `test/parity.test.ts`; `apps/docs/content/docs/brief.{en,ko}.mdx`.

**Step 1: Write failing tests** — `firma brief` renders a Watchlist
section; `get_brief` `watchlist` is populated; parity holds; each item
shows its relation and a `→ firma show valuation <ticker>` hint.

**Step 2:** Run — FAIL. **Step 3:** Implement. **Step 4:** PASS +
`yarn typecheck` / `yarn build` / `npx vitest run`.

**Step 5: Commit** — `feat: surface watchlist in brief (CLI + MCP)`

---

## Notes

- `@firma/brief` depends on `@firma/advisor`; never the reverse.
- The advisor is pure — it fetches nothing. All external data
  (macro, peers, news) is gathered by `@firma/brief` and passed in
  `AdvisorContext`.
- `risk_tolerance` only ever maps to `severity`; it never gates whether
  a recommendation fires.
- `insights` (the old prose `BriefInsight[]`) stays through Phase 1 for
  renderer compatibility; a later cleanup may retire it once `moves`
  fully supersedes it — out of scope here.
