SLOPSHOPPER

stonks

Swing-trading bookkeeping: reconciles the user's mirrors against IBKR and keeps the Simply Wall St watchlist in step with the tracking sheet, with code making…

newpaneguard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · stonks
│ ┃ stonks ✕ › fix the failing auth test and add an audit log call │ ┃ No report yet. Run /stonks:sync; the │ ┃ markdown report is always printed in the ⏺ Read(src/auth.ts) │ ┃ transcript. ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · stonks
No report yet. Run /stonks:sync; the markdown report is always printed in the transcript.
README

stonks

Swing-trading bookkeeping. The routine used to run as two Claude Cowork tasks, always back to back: one reconciles the mirrors against IBKR, the other cleans the Simply Wall St watchlist. Over 23 runs each they failed in the same ways: IBKR read from screenshots, stale data reused, scraping noise in the report, a removal taken from memory instead of the live watchlist, a fuzzy search adding the wrong listing.

Every decision in those tasks is deterministic, and official read paths now exist for the inputs that were pasted or photographed: IBKR's MCP server for positions and orders, and gws for the tracking sheet. This plugin moves the procedure into code: the engine makes every decision, and the LLM only drives the browser and calls MCP tools. The domain terms (mirror, tracking sheet, Estado, Watchlist candidate, keeper) are defined in CONTEXT.md.

Install

/plugin marketplace add pabloimrik17/daily-agentic-task-force
/plugin install stonks@daily-agentic-task-force

Usage

/stonks:sync [--only sources|watchlist]

Only the user can invoke it. Phase 1 reads every input live, reports discrepancies, warnings and the Unprotected position alert, and writes to none of them. When a finding affects the watchlist's composition the command pauses once, showing the whole report, and continues on "sigue". Phase 2 makes the private Simply Wall St watchlist equal the candidate set under guardrails. --only sources runs phase 1 alone; --only watchlist runs phase 2 alone.

Requirements

  • bun on PATH, at the version in the repository's .bun-version.
  • gws (googleworkspace-cli) on PATH, tested with 0.22.5. It reads the tracking sheet and must be authorised with exactly the https://www.googleapis.com/auth/spreadsheets.readonly scope. Setup: your own GCP project, a Desktop OAuth client, the consent screen in production, then gws auth login --scopes https://www.googleapis.com/auth/spreadsheets.readonly.
  • The IBKR MCP server registered at user scope as ibkr and connected through /mcp. The command pre-approves only mcp__ibkr__get_account_positions and mcp__ibkr__get_account_orders. Every other IBKR tool asks for permission as usual.
  • Claude in Chrome, for the browser collectors.
  • Claude Code 2.1.289 or later. That is the version the plugin was developed and tested against; the report pane uses the early-access mod API.
  • The configuration file.

The plugin installs nothing. The per-user configuration and the tooling install (gws, the ibkr server registration) are managed outside this repository, in the user's dotfiles.

Configuration

The personal values live in one JSON file outside the plugin, validated before any input is read.

  • Path: ~/.config/stonks/config.json.
  • Override: STONKS_CONFIG holds the path of another file.
  • Schema id: stonks.config.v1.

config.example.json ships with the plugin:

{
    "schema": "stonks.config.v1",
    "trackingSheet": { "spreadsheetId": "<SPREADSHEET_ID>", "tab": "<TAB_NAME>" },
    "swsPortfolio": { "url": "https://simplywall.st/portfolio/<PORTFOLIO_ID>" },
    "watchlist": { "name": "<WATCHLIST_NAME>", "url": "https://simplywall.st/watchlist" },
    "carteraViva": { "url": "https://<CARTERA_VIVA_PAGE>" },
    "excludedTickers": ["<TICKER>"]
}
FieldMeaning
schemaAlways stonks.config.v1.
trackingSheet.spreadsheetIdThe Google spreadsheet gws reads.
trackingSheet.tabThe tab of that spreadsheet, addressed by name.
swsPortfolio.urlThe Simply Wall St portfolio page.
watchlist.nameThe name of the watchlist phase 2 manages. The engine refuses a watchlist of another name.
watchlist.urlThe Simply Wall St watchlist page.
carteraViva.urlThe Cartera Viva page.
excludedTickersPositions outside the swing strategy, e.g. a long-term ETF. Checks B and C ignore them. Check A still compares them, since the SWS portfolio mirrors IBKR fully.

The validation is strict:

  • Every field is required.
  • Unknown fields are rejected.
  • URLs must start with https://.
  • Strings are non-empty and have no leading or trailing whitespace.
  • excludedTickers entries follow the same whitespace rule, are upper-case and are unique.

Nothing else is configurable. There is no default for any field.

An invalid file is an error that names the file and the path of the offending field, for example $.watchlist.url must be an https URL, prefixed with the file path and the schema id. A missing file is an error that names the expected path and points to the shipped example.

Data handling

Personal financial data lives only in the configuration file, the local state directory and your own services. The repository holds fictional fixtures and examples only.

The state directory is, in order of precedence:

  1. STONKS_STATE_DIR;
  2. $XDG_STATE_HOME/stonks;
  3. ~/.local/state/stonks.
stonks/
  active.json            # { runId, startedAt, mode } while a run is open
  runs/<runId>/          # one run only
    raw/                 # hook captures, verbatim
    warnings.json        # unknown IBKR tools reported
    ibkr-reauth-offered.json  # the /mcp offer was made
    ibkr-staged.json     # fallback table awaiting confirmation
    ibkr-confirmed.json  # fallback table, after confirmation
    report.json          # stonks.report.v1, read by the pane
    gate-resumed.json    # `sigue` ran
    plan.json            # watchlist plan
    previous.json        # snapshot handed over from the previous run, consumed once
  previous.json          # handoff from the pane, moved into the next run by `begin`
  listings.json          # learnt TICKER -> { symbol, name, url }
  • Directories are created with mode 0700 and files with 0600, whatever the umask.
  • begin deletes everything under runs/ before any input is read. Data from a previous run is gone by construction, not by instruction.
  • An active run expires 6 hours after it started. Past that, its captures are ignored.
  • A second begin replaces the active run. One sync at a time is supported.
  • Two things are kept between runs: the previous-run snapshot and the learnt listings. Neither is ever used as input.
  • Raw IBKR responses and browser collector results are captured by a plugin PostToolUse hook, verbatim. The model never retypes them.
  • Nothing captured during a run is published to a hosted service. There are no Artifacts.

What decides what

Every part is tiered: code first, the LLM last. No part needs a typed judgement, so no Jev step exists.

PartTier
Configuration loading and validationcode
Calling the two IBKR reads; driving the browserLLM (tool calls only; it never handles the returned data)
Capturing raw tool resultscode (plugin hook)
Reading the tracking sheetcode (gws adapter)
Validating and normalising every inputcode
Offering /mcp re-authentication when IBKR failsLLM relays the engine's directive; the user acts
Transcribing IBKR screenshots (fallback only)LLM, gated by the user's confirmation of the engine's rendering
Comparing available mcp__ibkr__* tool names with the known setcode (the LLM only passes the names)
Checks, gate, watchlist plan, listing choice, verificationcode
Search term for a ticker whose company name the engine lacksLLM (a hint only; the listing is selected by exact match)
Report data, markdown, Movimientos, counterscode
Panecode (mod)
Relaying output; asking the user (gate, login, screenshots)LLM

Checks

Phase 1 compares each mirror with IBKR and the tracking sheet with the Cartera Viva. Every result is a finding with a severity; the findings marked "yes" under Gate affect the watchlist's composition and pause the run once after the report (see the glossary in CONTEXT.md for the terms).

CheckMeaningSeverityGate
A1Missing in SWS: a held ticker the SWS portfolio does not listDiscrepancyno
A2Extra in SWS: an SWS portfolio ticker that is not heldDiscrepancyno
B1Entry without position: Invertido or Vender entries on a ticker IBKR does not holdDiscrepancyyes
B2Position without entry: a held ticker with no Invertido or Vender entryDiscrepancyyes
B3Quantity mismatch: the sheet's position differs from IBKR's quantityDiscrepancyno
B4Comprar without order: a Comprar entry with no live buy order left to matchDiscrepancyyes
B5Buy order without entry: a live buy order with no Comprar entry left to matchDiscrepancyyes
B6Buy order mismatch: a Comprar entry and a live buy order that differ in quantity or limit priceDiscrepancyno
B7Sell orders beyond Vender entries: live sell orders cover more shares than the Vender totalDiscrepancyyes
B8Unprotected position: a held ticker whose live sell orders cover fewer shares than its Vender totalAlertno
C1Stale Roger: a Roger entry on a ticker not in the Cartera VivaDiscrepancyyes
C2Should be Roger: a Cartera Viva ticker not held, with no Roger, Comprar, Invertido or Vender entryDiscrepancyyes
C3Sold while the Trader is still in: a Cartera Viva ticker not held that still has Invertido or Vender entriesDiscrepancyyes
C4Pending buy with the Trader in: a Cartera Viva ticker with a Comprar entry and a live buy orderInformationalno
C5Missing own trailing: the Trader's trailing is activated, the user holds Invertido with no Vender and no sell orderWarningno
C6Trailing mismatch: the user's trailing sell order trails a different percentage than the Trader's; not evaluable when the user's trail is unknown or the sell order is not a trailing stopWarning / Not evaluableno
C7The Trader exited, the user is still in: a held ticker with an Invertido entry not in the Cartera VivaDiscrepancyyes
C8Vender with the Trader out: a Vender entry on a ticker not in the Cartera Viva that IBKR no longer holdsDiscrepancyyes
C9Live buy on a ticker the Trader is not in: a Comprar entry or a live buy order on a ticker not in the Cartera VivaInformationalno

Phase 2

Phase 2 makes the private Simply Wall St watchlist equal the candidate set. A ticker is a candidate when every one of its entries in the tracking sheet is in Comprar, Roger or Operativa. One entry in any other Estado disqualifies it.

  • Plan. In a full run, planning starts only after phase 1, and after "sigue" when the gate paused the run: the engine refuses to plan otherwise, so the pause does not rest on the command alone. The engine reads the tracking sheet and the watchlist live, and plans from those two reads, never from a list remembered from an earlier run. The removals are the watchlist tickers that are not candidates. The additions are the candidates the watchlist lacks. The plan is applied in the same run without asking: invoking the command is the authorisation.
  • Order and capacity. Every removal is applied before any addition. The capacity is the one the watchlist page shows. If the candidate set is larger, nothing is changed and the report states both numbers.
  • Exact listing. Each addition is resolved to one EXCHANGE:TICKER listing, preferring the US primary one (NYSE, NasdaqGS, NasdaqGM or NasdaqCM). The engine supplies the search term when it knows the company name; otherwise it asks the LLM to search by the company's name, as a hint. Only the search result whose symbol equals the listing is clicked. The first result, a similar ticker or a position on screen never decides.
  • Per-change verification. After each removal and each addition the engine checks that a fresh watchlist read differs from the previous one by exactly that change. Simply Wall St's confirmations name no ticker, so they are not read.
  • Final comparison. The final step refuses to run while a change is still pending. The watchlist is read once more and compared with the candidate set exactly. The report lists the tickers removed, the tickers added, any left unresolved, the final list, and the count against the capacity, each ticker linked to its Simply Wall St page.
  • Resumption. An interrupted phase 2 is resumed with /stonks:sync --only watchlist. It plans again from the live sheet and the live watchlist, so changes already made are not repeated.

Guardrails:

  • A keeper, a candidate already on the watchlist, is never removed, not even to diagnose the page.
  • Any outcome other than the expected change stops phase 2 and reports the difference. The command does not try to repair it by experimenting on the watchlist.
  • When no search result matches the listing exactly, or only a non-US listing exists, the ticker is not added. It is reported as unresolved and the command asks you for its exact listing. The run does not act on your answer: add the listing by hand once the run has ended, and the next run learns it from the watchlist.

Report pane

When /stonks:sync runs in a terminal or in the Desktop app's Code tab, the plugin's hooks module (mod/register.ts) opens a pane above the prompt, or beside the transcript where the layout docks panes, and draws the same report as the markdown: alerts first, one collapsible section per mirror, ticker links, Movimientos, the checklist when the gate pauses the run, and phase 2's result once watchlist-final has run. The markdown stays the run's output. The pane adds interaction only, so a run is complete without it: when mods are disabled, the surface places no panes or the pane fails, the markdown report is all there is.

The pane opens in a "syncing" state when the command starts and shows the report after phase1; sigue and watchlist-final replace it. A watchlist-final that names no report, because it stopped, leaves the pane as it is. With --only watchlist there is no phase-1 report: the pane says so, and phase 2's result is in the transcript only. A new run replaces the previous run's report and clears every tick. The ticks also clear when the report that "sigue" recomputes loads. The checklist asks for "sigue" only on a full run's phase-1 report. A pane that fails to open does not stop the command.

Keys, while the pane holds the keyboard (ctrl+x tab moves the focus into it):

KeyAction
1, 2, 3Collapse or expand the SWS portfolio, tracking sheet and Cartera Viva
a to zTick or untick the checklist item in that position (gate only)
Tab, EnterWalk the links and buttons; Enter follows a link or presses a button
EscReturn the focus to the prompt
ctrl+x xClose the pane

Ticks live in the session only. They are never stored and never change a result: after "sigue" every finding is recomputed from the re-read sheet.

  • Tested version. Claude Code 2.1.289. The mod API is early access and may change between releases; the vendored declarations under mod/types/ are pinned to that version.
  • Local only. The pane, its session state and its store (the previous-run snapshot, kept in the plugin's own store under the Claude Code configuration directory) stay on this machine. Nothing is published.
  • Snapshot handoff. When the command starts, the mod writes its stored snapshot to <state>/previous.json; begin moves it into the run directory, where the engine reads it once for Movimientos and the repeat counters. After each report-producing step the mod stores the report's snapshot again, so the store follows the last report of the run.
  • Mod tests. bun run test:mod in plugins/stonks runs mod/register.test.ts under claude plugin test, on a scratch plugin root holding the manifest and mod/ alone (the runner loads every *.test.ts beneath its root, and the Vitest suites cannot run there). The tests run locally, not in CI. The runner needs the hooks-modules rollout switch on; when the cached switch is off it refuses with "hooks modules are turned off in this process", and starting claude once with network access refreshes the cache.
Source 2 files
mod/register.ts 547 lines
1// The report pane (design D13, D14). The engine always prints the markdown as
2// well, so nothing here is required for a run to complete.
3//
4// `$.state` is read and written directly, each call naming one of the file's
5// reference constants: the validators of both the CI-pinned and the running
6// Claude Code follow that form, while the `claude-code` state library
7// (`atom`, `read`, `update`) is unknown to the pinned one (design D17).
8
9import type { CommandRunResult, EngineInterface, Register, RenderElement } from "claude-code";
10
11import type {
12    PaneFinding,
13    PaneMirror,
14    PaneMovement,
15    PaneReport,
16    PaneSection,
17    PaneStatus,
18    PaneWatchlist,
19} from "./types/stonks-state";
20
21const PANE = "stonks";
22
23const STATUS = { plugin: "stonks", key: "status" } as const;
24const REPORT = { plugin: "stonks", key: "report" } as const;
25const COLLAPSED = { plugin: "stonks", key: "collapsed" } as const;
26const TICKS = { plugin: "stonks", key: "ticks" } as const;
27const FULL_RUN = { plugin: "stonks", key: "fullRun" } as const;
28const GATE_WAIT = { plugin: "stonks", key: "gateWait" } as const;
29
30// The plugin root is `…/plugins/stonks` under `--plugin-dir`, and
31// `…/cache/<marketplace>/stonks/<version>` once installed from a marketplace.
32const STEP = /stonks\/(?:[^/\s"]+\/)?src\/cli\.ts"?\s+(phase1|sigue|watchlist-final)\b/;
33const PATH_LINE = /^stonks-report-path: (.+)$/m;
34const ONLY = /--only\b/;
35const PHASE2_ONLY = /--only\s+watchlist\b/;
36const REPORT_SCHEMA = "stonks.report.v1";
37
38const MIRROR_TITLE: Record<PaneSection["mirror"], string> = {
39    "sws-portfolio": "SWS portfolio",
40    "tracking-sheet": "Tracking sheet",
41    "cartera-viva": "Cartera Viva",
42};
43
44// The A and B checks compare a mirror with IBKR; the C checks compare the
45// tracking sheet with the Cartera Viva.
46const AGREES: Record<PaneSection["mirror"], string> = {
47    "sws-portfolio": "Agrees with IBKR.",
48    "tracking-sheet": "Agrees with IBKR.",
49    "cartera-viva": "The tracking sheet agrees with the Cartera Viva.",
50};
51
52// The tracking sheet's text when its B findings all went to the Alerts: the
53// engine moves every B8 there.
54const SEE_ALERTS = "No other finding; see Alerts.";
55
56function agrees(r: PaneReport, mirror: PaneMirror): string {
57    return mirror === "tracking-sheet" && r.alerts.some((f) => f.check.startsWith("B"))
58        ? SEE_ALERTS
59        : AGREES[mirror];
60}
61
62// Only a full run's `phase1` waits for "sigue": `--only sources` ends after its
63// report, and the report `sigue` prints goes on to phase 2.
64const GATE_HEADING = {
65    wait: 'Gate: fix in the tracking sheet, then say "sigue"',
66    list: "Gate: findings that affect the watchlist",
67} as const;
68
69const MIRROR_HOTKEY: Record<PaneSection["mirror"], string> = {
70    "sws-portfolio": "1",
71    "tracking-sheet": "2",
72    "cartera-viva": "3",
73};
74
75const SEVERITY_LABEL: Record<PaneFinding["severity"], string> = {
76    alert: "ALERT",
77    discrepancy: "Discrepancy",
78    warning: "warning",
79    informational: "info",
80    "not-evaluable": "not evaluable",
81};
82
83const isRecord = (value: unknown): value is Record<string, unknown> =>
84    typeof value === "object" && value !== null;
85
86const REPORT_SHAPE: Record<string, (value: unknown) => boolean> = {
87    schema: (value) => value === REPORT_SCHEMA,
88    ibkr: isRecord,
89    counts: isRecord,
90    warnings: Array.isArray,
91    alerts: Array.isArray,
92    sections: Array.isArray,
93    movements: isRecord,
94    gate: isRecord,
95    links: isRecord,
96};
97
98function isReport(value: unknown): value is PaneReport {
99    return (
100        isRecord(value) && Object.entries(REPORT_SHAPE).every(([key, holds]) => holds(value[key]))
101    );
102}
103
104function firstNonEmpty(...values: (string | undefined)[]): string {
105    for (const value of values) {
106        if (value !== undefined && value !== "") {
107            return value;
108        }
109    }
110    return "";
111}
112
113async function stateDir($: EngineInterface): Promise<string> {
114    const override = firstNonEmpty(await $.env.get("STONKS_STATE_DIR"));
115    if (override !== "") {
116        return override;
117    }
118    const xdg = await $.env.get("XDG_STATE_HOME");
119    const home = await $.env.get("HOME");
120    return `${firstNonEmpty(xdg, `${home ?? ""}/.local/state`)}/stonks`;
121}
122
123function sidesText(f: PaneFinding): string {
124    return Object.entries(f.sides)
125        .map(([source, says]) => `${source}: ${says}`)
126        .join("; ");
127}
128
129type ChecklistItem = { key: string; label: string; ticker: string };
130
131/** B4 and B5 can raise one finding per entry or order of a ticker; each later one gets its ordinal. */
132function checklistItems(r: PaneReport): ChecklistItem[] {
133    const affected = [...r.alerts, ...r.sections.flatMap((s) => s.findings)].filter(
134        (f) => f.affectsWatchlist,
135    );
136    const seen = new Map<string, number>();
137    return affected.map((f) => {
138        const key = `${f.check}:${f.ticker}`;
139        const nth = (seen.get(key) ?? 0) + 1;
140        seen.set(key, nth);
141        return {
142            key: nth === 1 ? key : `${key}:${nth}`,
143            label: `${f.check} ${f.ticker}`,
144            ticker: f.ticker,
145        };
146    });
147}
148
149type Ui = ReturnType<EngineInterface["ui"]["resolve"]>;
150
151// The findings are a grid, not a `Markdown` table: Markdown lays a table out
152// for the terminal's width, and a wide one breaks in a pane docked beside the
153// transcript. The first three cells are fixed; the sides take what is left of
154// the pane's body and wrap inside it.
155const ROW = { flexDirection: "row", columnGap: 1 } as const;
156const CHECK_CELL = { width: 5, flexShrink: 0 } as const;
157const TICKER_CELL = { width: 7, flexShrink: 0 } as const;
158const SEVERITY_CELL = { width: 16, flexShrink: 0 } as const;
159const SIDES_CELL = { flexGrow: 1, flexShrink: 1 } as const;
160
161function tickerLink(ui: Ui, ticker: string, links: Record<string, string>) {
162    const href = links[ticker];
163    return href === undefined ? h(ui.Text, null, ticker) : h(ui.Link, { href, label: ticker });
164}
165
166function findingRow(ui: Ui, f: PaneFinding, links: Record<string, string>) {
167    const repeat = f.repeat === undefined ? "" : ` ×${f.repeat}`;
168    const gate = f.affectsWatchlist ? " ⚑" : "";
169    const reason = f.reason === undefined ? "" : ` (${f.reason})`;
170    return h(
171        ui.Box,
172        ROW,
173        h(ui.Box, CHECK_CELL, h(ui.Text, null, `${f.check}${gate}`)),
174        h(ui.Box, TICKER_CELL, tickerLink(ui, f.ticker, links)),
175        h(ui.Box, SEVERITY_CELL, h(ui.Text, null, `${SEVERITY_LABEL[f.severity]}${repeat}`)),
176        h(ui.Box, SIDES_CELL, h(ui.Text, { wrap: "wrap" }, `${sidesText(f)}${reason}`)),
177    );
178}
179
180function headerRow(ui: Ui) {
181    const head = (text: string) => h(ui.Text, { bold: true, dimColor: true }, text);
182    return h(
183        ui.Box,
184        ROW,
185        h(ui.Box, CHECK_CELL, head("Check")),
186        h(ui.Box, TICKER_CELL, head("Ticker")),
187        h(ui.Box, SEVERITY_CELL, head("Severity")),
188        h(ui.Box, SIDES_CELL, head("Sides")),
189    );
190}
191
192function findingsGrid(
193    ui: Ui,
194    key: string,
195    findings: PaneFinding[],
196    links: Record<string, string>,
197    agrees: string,
198) {
199    const rows =
200        findings.length === 0
201            ? [h(ui.Text, { dimColor: true }, agrees)]
202            : [headerRow(ui), ...findings.map((f) => findingRow(ui, f, links))];
203    return h(ui.Box, { key, flexDirection: "column" }, ...rows);
204}
205
206const SECTION_BOX = {
207    flexDirection: "column",
208    borderStyle: "single",
209    borderDimColor: true,
210    paddingX: 1,
211} as const;
212
213function stdoutOf(ran: { result?: unknown; text?: string }): string {
214    const result = ran.result as { stdout?: string } | undefined;
215    return result?.stdout ?? ran.text ?? "";
216}
217
218/** The report the step named, or null when the line, the file or its shape is missing. */
219async function loadReport($: EngineInterface, stdout: string): Promise<PaneReport | null> {
220    const reportPath = PATH_LINE.exec(stdout)?.[1]?.trim();
221    if (reportPath === undefined) {
222        return null;
223    }
224    try {
225        const parsed: unknown = JSON.parse(await $.fs.read(reportPath));
226        return isReport(parsed) ? parsed : null;
227    } catch {
228        return null;
229    }
230}
231
232/**
233 * Ticks belong to the report they were made on: `sigue` recomputes every
234 * finding, so its report, like any other new one, starts unticked.
235 */
236async function settleTicks($: EngineInterface, step: string, loaded: PaneReport): Promise<void> {
237    const { value: shown } = await $.state.get(REPORT);
238    if (step === "sigue" || shown?.generatedAt !== loaded.generatedAt) {
239        await $.state.set(TICKS, {});
240    }
241}
242
243async function storeReport($: EngineInterface, step: string, loaded: PaneReport): Promise<void> {
244    const { value: fullRun = false } = await $.state.get(FULL_RUN);
245    await settleTicks($, step, loaded);
246    await $.state.set(REPORT, loaded);
247    await $.state.set(GATE_WAIT, fullRun && step === "phase1");
248    await $.state.set(STATUS, "ready");
249    // D14: the store keeps the snapshot between sessions; every step rewrites
250    // `report.json`, so it follows the run's last report.
251    await $.store.set("snapshot", loaded.snapshot);
252}
253
254/**
255 * A step's report into the pane. `watchlist-final` names none in
256 * `--only watchlist`, or when it stops; the pane then keeps what it shows.
257 */
258async function follow($: EngineInterface, step: string, stdout: string): Promise<void> {
259    if (step === "watchlist-final" && !PATH_LINE.test(stdout)) {
260        return;
261    }
262    const loaded = await loadReport($, stdout);
263    await (loaded === null ? showUnloaded($) : storeReport($, step, loaded));
264}
265
266/** A missing or invalid `report.json`: the pane points at the markdown instead. */
267async function showUnloaded($: EngineInterface): Promise<void> {
268    await $.state.set(REPORT, null);
269    await $.state.set(STATUS, "error");
270}
271
272/** D14 handoff: the stored snapshot goes to the engine, consumed once by `begin`. */
273async function handOver($: EngineInterface): Promise<void> {
274    const snapshot = await $.store.get("snapshot");
275    if (snapshot !== undefined) {
276        await $.fs.write(`${await stateDir($)}/previous.json`, JSON.stringify(snapshot));
277    }
278}
279
280const NO_REPORT =
281    "No report yet. Run /stonks:sync; the markdown report is always printed in the transcript.";
282
283const HINTS: Record<PaneStatus, string> = {
284    idle: NO_REPORT,
285    syncing:
286        "Syncing… the report appears here after phase 1. The markdown report is printed in the transcript as well.",
287    watchlist:
288        "Phase 2 alone (--only watchlist) has no phase-1 report to draw here; its watchlist result is printed in the transcript.",
289    ready: NO_REPORT,
290    error: "The report could not be loaded; read the markdown report in the transcript.",
291};
292
293async function emptyPane($: EngineInterface, ui: Ui) {
294    const { value: status = "idle" } = await $.state.get(STATUS);
295    return h(
296        ui.Box,
297        { flexDirection: "column", paddingX: 1 },
298        h(ui.Text, { dimColor: true }, HINTS[status]),
299    );
300}
301
302function headerLine(ui: Ui, r: PaneReport) {
303    return h(
304        ui.Text,
305        { dimColor: true },
306        `Run ${r.runId} · ${r.generatedAt} · IBKR via ${r.ibkr.provenance}: ${r.ibkr.positions} positions, ${r.ibkr.orders} orders · SWS ${r.counts.swsPortfolio} · Cartera Viva ${r.counts.carteraViva}`,
307    );
308}
309
310function alertsBox(ui: Ui, r: PaneReport) {
311    const { Box, Text } = ui;
312    return r.alerts.length === 0
313        ? h(Text, { dimColor: true }, "No alerts.")
314        : h(
315              Box,
316              { flexDirection: "column", borderStyle: "round", borderColor: "red", paddingX: 1 },
317              h(Text, { bold: true, color: "red" }, `Alerts (${r.alerts.length})`),
318              findingsGrid(ui, "findings:alerts", r.alerts, r.links, "No alerts."),
319          );
320}
321
322async function toggleMirror($: EngineInterface, mirror: PaneMirror): Promise<void> {
323    const held = await $.state.get(COLLAPSED);
324    const current = held.value ?? {};
325    await $.state.set(COLLAPSED, { ...current, [mirror]: !(current[mirror] === true) });
326}
327
328function mirrorToggle($: EngineInterface, ui: Ui, s: PaneSection, isCollapsed: boolean) {
329    return h(ui.Button, {
330        key: `toggle:${s.mirror}`,
331        hotkey: MIRROR_HOTKEY[s.mirror],
332        label: `${isCollapsed ? "▸" : "▾"} ${MIRROR_TITLE[s.mirror]} (${s.findings.length})`,
333        onPress: () => toggleMirror($, s.mirror),
334    });
335}
336
337function sectionBox(
338    $: EngineInterface,
339    ui: Ui,
340    s: PaneSection,
341    r: PaneReport,
342    collapsed: Record<string, boolean>,
343) {
344    const isCollapsed = collapsed[s.mirror] === true;
345    const body = isCollapsed
346        ? []
347        : [findingsGrid(ui, `findings:${s.mirror}`, s.findings, r.links, agrees(r, s.mirror))];
348    return h(ui.Box, SECTION_BOX, mirrorToggle($, ui, s, isCollapsed), ...body);
349}
350
351function linksRow(ui: Ui, r: PaneReport) {
352    const tickers = Object.entries(r.links).slice(0, 12);
353    return h(
354        ui.Box,
355        { flexDirection: "row", flexWrap: "wrap", gap: 1 },
356        h(ui.Text, { dimColor: true }, "Tickers:"),
357        ...tickers.map(([ticker, href]) => h(ui.Link, { href, label: ticker })),
358    );
359}
360
361function movementRow(ui: Ui, m: PaneMovement, links: Record<string, string>) {
362    const side = m.side === null ? [] : [h(ui.Text, null, `(${m.side})`)];
363    return h(
364        ui.Box,
365        ROW,
366        h(ui.Text, null, `${m.kind} ${m.quantity}`),
367        tickerLink(ui, m.ticker, links),
368        ...side,
369    );
370}
371
372function noMovements(ui: Ui, r: PaneReport) {
373    return h(
374        ui.Text,
375        { dimColor: true },
376        r.movements.previousRunDate === null
377            ? "Movimientos: no previous run to compare with."
378            : `Movimientos since ${r.movements.previousRunDate}: none.`,
379    );
380}
381
382function listedMovements(ui: Ui, r: PaneReport) {
383    return h(
384        ui.Box,
385        { flexDirection: "column" },
386        h(ui.Text, { bold: true }, `Movimientos since ${r.movements.previousRunDate ?? "?"}`),
387        ...r.movements.items.map((m) => movementRow(ui, m, r.links)),
388    );
389}
390
391function movementsBox(ui: Ui, r: PaneReport) {
392    return r.movements.items.length === 0 ? noMovements(ui, r) : listedMovements(ui, r);
393}
394
395const WRAP_ROW = { flexDirection: "row", flexWrap: "wrap", columnGap: 1 } as const;
396
397function tickerLine(ui: Ui, label: string, tickers: string[], links: Record<string, string>) {
398    const items =
399        tickers.length === 0
400            ? [h(ui.Text, { dimColor: true }, "none")]
401            : tickers.map((ticker) => tickerLink(ui, ticker, links));
402    return h(ui.Box, WRAP_ROW, h(ui.Text, null, `${label}:`), ...items);
403}
404
405function incompleteLines(ui: Ui, w: PaneWatchlist, links: Record<string, string>) {
406    return w.incomplete === null
407        ? []
408        : [
409              h(ui.Text, { bold: true, color: "red" }, "Incomplete"),
410              tickerLine(ui, "Missing", w.incomplete.missing, links),
411              tickerLine(ui, "Unexpected", w.incomplete.extra, links),
412          ];
413}
414
415/** Phase 2's result, once `watchlist-final` has added it to the report. */
416function watchlistBox(ui: Ui, r: PaneReport) {
417    const w = r.watchlist ?? null;
418    if (w === null) {
419        return [];
420    }
421    return [
422        h(
423            ui.Box,
424            { key: "watchlist", ...SECTION_BOX },
425            h(ui.Text, { bold: true }, `Watchlist ${w.count}/${w.capacity}`),
426            tickerLine(ui, "Removed", w.removed, r.links),
427            tickerLine(ui, "Added", w.added, r.links),
428            tickerLine(ui, "Unresolved", w.unresolved, r.links),
429            tickerLine(ui, "Final list", w.final, r.links),
430            ...incompleteLines(ui, w, r.links),
431        ),
432    ];
433}
434
435async function toggleTick($: EngineInterface, key: string): Promise<void> {
436    const held = await $.state.get(TICKS);
437    const current = held.value ?? {};
438    await $.state.set(TICKS, { ...current, [key]: !(current[key] === true) });
439}
440
441function tickButton(
442    $: EngineInterface,
443    ui: Ui,
444    item: ChecklistItem,
445    index: number,
446    ticks: Record<string, boolean>,
447) {
448    return h(ui.Button, {
449        key: `tick:${item.key}`,
450        plain: true,
451        ...(index < 26 ? { hotkey: String.fromCharCode(97 + index) } : {}),
452        label: `${ticks[item.key] === true ? "[x]" : "[ ]"} ${item.label}`,
453        onPress: () => toggleTick($, item.key),
454    });
455}
456
457function checklistBox(
458    $: EngineInterface,
459    ui: Ui,
460    r: PaneReport,
461    ticks: Record<string, boolean>,
462    heading: string,
463) {
464    const items = checklistItems(r);
465    if (!r.gate.tripped || items.length === 0) {
466        return [];
467    }
468    return [
469        h(
470            ui.Box,
471            { flexDirection: "column", borderStyle: "round", paddingX: 1 },
472            h(ui.Text, { bold: true }, heading),
473            ...items.map((item, index) =>
474                h(
475                    ui.Box,
476                    { key: `item:${item.key}`, ...ROW },
477                    tickButton($, ui, item, index, ticks),
478                    tickerLink(ui, item.ticker, r.links),
479                ),
480            ),
481        ),
482    ];
483}
484
485async function reportPane($: EngineInterface, ui: Ui, r: PaneReport, bodyColumns: number) {
486    const { value: collapsed = {} } = await $.state.get(COLLAPSED);
487    const { value: ticks = {} } = await $.state.get(TICKS);
488    const { value: gateWait = false } = await $.state.get(GATE_WAIT);
489    return h(
490        ui.Box,
491        // Sized to the pane's body, which is narrower than the terminal while docked.
492        { flexDirection: "column", gap: 1, paddingX: 1, width: bodyColumns },
493        headerLine(ui, r),
494        ...r.warnings.map((w) => h(ui.Text, { color: "yellow" }, `WARNING: ${w}`)),
495        alertsBox(ui, r),
496        ...r.sections.map((s) => sectionBox($, ui, s, r, collapsed)),
497        linksRow(ui, r),
498        movementsBox(ui, r),
499        ...checklistBox($, ui, r, ticks, gateWait ? GATE_HEADING.wait : GATE_HEADING.list),
500        ...watchlistBox(ui, r),
501    );
502}
503
504/** A run's start: the pane reset to syncing, the snapshot handed over, then the pane opened. */
505async function startRun($: EngineInterface, args: string): Promise<void> {
506    await $.state.set(STATUS, PHASE2_ONLY.test(args) ? "watchlist" : "syncing");
507    await $.state.set(FULL_RUN, !ONLY.test(args));
508    await $.state.set(REPORT, null);
509    await $.state.set(TICKS, {});
510    await $.state.set(COLLAPSED, {});
511    await handOver($);
512    await $.ui.open({ id: PANE, title: "Stonks" });
513}
514
515export const register: Register = (on) => {
516    on("command.run", { command: "stonks:sync" }, async ($, e, next) => {
517        // The command runs whatever happens to the pane. A failure still fails
518        // the hook, so the engine reports it, but after `next`, whose result
519        // then stands.
520        let ran: CommandRunResult;
521        try {
522            await startRun($, e.args);
523        } finally {
524            ran = await next(e);
525        }
526        return ran;
527    });
528
529    on("tool.call", { tool: "Bash" }, async ($, e, next) => {
530        const ran = await next(e);
531        const step = STEP.exec(e.command)?.[1];
532        if (step === undefined || ran.deny !== undefined) {
533            return ran;
534        }
535        await follow($, step, stdoutOf(ran));
536        return ran;
537    });
538
539    on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
540        const ui = $.ui.resolve(e);
541        const { value: r = null } = await $.state.get(REPORT);
542        const pane =
543            r === null ? await emptyPane($, ui) : await reportPane($, ui, r, e.props.bodyColumns);
544        return pane as RenderElement;
545    });
546};
547
mod/types/stonks-state.d.ts 77 lines
1// The pane's state contract (design D13, D17): the values the mod keeps in
2// `$.state`, declared under the plugin's name so that `claude plugin validate`
3// can hold every `$.state` key the module names to it. The report shapes
4// mirror `src/domain.ts`; the mod cannot import that file at run time, so the
5// subset it draws is restated here as types only.
6
7export type PaneSeverity = "alert" | "discrepancy" | "warning" | "informational" | "not-evaluable";
8
9export type PaneFinding = {
10    check: string;
11    ticker: string;
12    sides: Record<string, string>;
13    severity: PaneSeverity;
14    affectsWatchlist: boolean;
15    reason?: string;
16    repeat?: number;
17};
18
19export type PaneMirror = "sws-portfolio" | "tracking-sheet" | "cartera-viva";
20
21export type PaneSection = { mirror: PaneMirror; findings: PaneFinding[] };
22
23export type PaneMovement = {
24    kind: "fill" | "triggered-sell" | "new-order" | "cancelled-order";
25    ticker: string;
26    quantity: number;
27    side: "buy" | "sell" | null;
28};
29
30/** Phase 2's result, which `watchlist-final` adds to the report. */
31export type PaneWatchlist = {
32    removed: string[];
33    added: string[];
34    unresolved: string[];
35    final: string[];
36    count: number;
37    capacity: number;
38    incomplete: { missing: string[]; extra: string[] } | null;
39};
40
41export type PaneReport = {
42    schema: string;
43    runId: string;
44    generatedAt: string;
45    ibkr: { provenance: "mcp" | "screenshots"; positions: number; orders: number };
46    counts: { swsPortfolio: number; carteraViva: number };
47    warnings: string[];
48    alerts: PaneFinding[];
49    sections: PaneSection[];
50    movements: { previousRunDate: string | null; items: PaneMovement[] };
51    gate: { tripped: boolean; affectedTickers: string[] };
52    links: Record<string, string>;
53    /** Absent from a report written before phase 2 had a place in the pane. */
54    watchlist?: PaneWatchlist | null;
55    snapshot: unknown;
56};
57
58/** `watchlist`: an `--only watchlist` run, which has no phase-1 report to draw. */
59export type PaneStatus = "idle" | "syncing" | "watchlist" | "ready" | "error";
60
61declare module "claude-code" {
62    interface PluginState {
63        stonks: {
64            status: PaneStatus;
65            report: PaneReport | null;
66            /** Mirror → collapsed. */
67            collapsed: Record<string, boolean>;
68            /** Checklist item key → ticked. Session only, never stored. */
69            ticks: Record<string, boolean>;
70            /** The run names no `--only`; set when it starts. */
71            fullRun: boolean;
72            /** The shown report is a full run's `phase1`, the one whose gate waits for "sigue". */
73            gateWait: boolean;
74        };
75    }
76}
77