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…

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.
/plugin marketplace add pabloimrik17/daily-agentic-task-force
/plugin install stonks@daily-agentic-task-force
/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.
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.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.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.
The personal values live in one JSON file outside the plugin, validated before any input is read.
~/.config/stonks/config.json.STONKS_CONFIG holds the path of another file.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>"]
}
| Field | Meaning |
|---|---|
schema | Always stonks.config.v1. |
trackingSheet.spreadsheetId | The Google spreadsheet gws reads. |
trackingSheet.tab | The tab of that spreadsheet, addressed by name. |
swsPortfolio.url | The Simply Wall St portfolio page. |
watchlist.name | The name of the watchlist phase 2 manages. The engine refuses a watchlist of another name. |
watchlist.url | The Simply Wall St watchlist page. |
carteraViva.url | The Cartera Viva page. |
excludedTickers | Positions 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:
https://.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.
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:
STONKS_STATE_DIR;$XDG_STATE_HOME/stonks;~/.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 }
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.begin replaces the active run. One sync at a time is supported.PostToolUse hook, verbatim. The model never retypes them.Every part is tiered: code first, the LLM last. No part needs a typed judgement, so no Jev step exists.
| Part | Tier |
|---|---|
| Configuration loading and validation | code |
| Calling the two IBKR reads; driving the browser | LLM (tool calls only; it never handles the returned data) |
| Capturing raw tool results | code (plugin hook) |
| Reading the tracking sheet | code (gws adapter) |
| Validating and normalising every input | code |
Offering /mcp re-authentication when IBKR fails | LLM 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 set | code (the LLM only passes the names) |
| Checks, gate, watchlist plan, listing choice, verification | code |
| Search term for a ticker whose company name the engine lacks | LLM (a hint only; the listing is selected by exact match) |
| Report data, markdown, Movimientos, counters | code |
| Pane | code (mod) |
| Relaying output; asking the user (gate, login, screenshots) | LLM |
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).
| Check | Meaning | Severity | Gate |
|---|---|---|---|
| A1 | Missing in SWS: a held ticker the SWS portfolio does not list | Discrepancy | no |
| A2 | Extra in SWS: an SWS portfolio ticker that is not held | Discrepancy | no |
| B1 | Entry without position: Invertido or Vender entries on a ticker IBKR does not hold | Discrepancy | yes |
| B2 | Position without entry: a held ticker with no Invertido or Vender entry | Discrepancy | yes |
| B3 | Quantity mismatch: the sheet's position differs from IBKR's quantity | Discrepancy | no |
| B4 | Comprar without order: a Comprar entry with no live buy order left to match | Discrepancy | yes |
| B5 | Buy order without entry: a live buy order with no Comprar entry left to match | Discrepancy | yes |
| B6 | Buy order mismatch: a Comprar entry and a live buy order that differ in quantity or limit price | Discrepancy | no |
| B7 | Sell orders beyond Vender entries: live sell orders cover more shares than the Vender total | Discrepancy | yes |
| B8 | Unprotected position: a held ticker whose live sell orders cover fewer shares than its Vender total | Alert | no |
| C1 | Stale Roger: a Roger entry on a ticker not in the Cartera Viva | Discrepancy | yes |
| C2 | Should be Roger: a Cartera Viva ticker not held, with no Roger, Comprar, Invertido or Vender entry | Discrepancy | yes |
| C3 | Sold while the Trader is still in: a Cartera Viva ticker not held that still has Invertido or Vender entries | Discrepancy | yes |
| C4 | Pending buy with the Trader in: a Cartera Viva ticker with a Comprar entry and a live buy order | Informational | no |
| C5 | Missing own trailing: the Trader's trailing is activated, the user holds Invertido with no Vender and no sell order | Warning | no |
| C6 | Trailing 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 stop | Warning / Not evaluable | no |
| C7 | The Trader exited, the user is still in: a held ticker with an Invertido entry not in the Cartera Viva | Discrepancy | yes |
| C8 | Vender with the Trader out: a Vender entry on a ticker not in the Cartera Viva that IBKR no longer holds | Discrepancy | yes |
| C9 | Live buy on a ticker the Trader is not in: a Comprar entry or a live buy order on a ticker not in the Cartera Viva | Informational | no |
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.
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./stonks:sync --only watchlist. It plans again from the live sheet and the live watchlist, so changes already made are not repeated.Guardrails:
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):
| Key | Action |
|---|---|
1, 2, 3 | Collapse or expand the SWS portfolio, tracking sheet and Cartera Viva |
a to z | Tick or untick the checklist item in that position (gate only) |
| Tab, Enter | Walk the links and buttons; Enter follows a link or presses a button |
| Esc | Return the focus to the prompt |
ctrl+x x | Close 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.
mod/types/ are pinned to that version.<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.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.mod/register.ts 547 lines1// 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};
547mod/types/stonks-state.d.ts 77 lines1// 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