@viccydev/pi-fpa 0.9.4 → 0.9.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,375 @@
1
+ import { realpath } from "node:fs/promises";
2
+ import { dirname } from "node:path";
3
+
4
+ import { runStructuredQuery } from "../fpa-data/runtime.ts";
5
+ import type { DashboardModuleBuild } from "./module-publisher.ts";
6
+ import type { DashboardWidget, TableCell, WaterfallDataset } from "./projector.ts";
7
+
8
+ // ============================================================================
9
+ // The portfolio overview module.
10
+ //
11
+ // Answers "how is the business doing" from the finance datasets, which the UA
12
+ // mart cannot reach: cash, cost structure, unit economics.
13
+ //
14
+ // Three constraints from the data contract are load-bearing here, and every
15
+ // one of them exists because breaking it produces a number that looks right
16
+ // and is wrong:
17
+ //
18
+ // * Opex is a company-level cost. Per-app figures stop at contribution
19
+ // margin; only the portfolio has FCF. Allocating opex across apps would
20
+ // manufacture precision nobody measured.
21
+ //
22
+ // * Runway is cash over fixed outflow, not over net burn. A burn-based
23
+ // runway is null or infinite while FCF is positive, which tells a reader
24
+ // nothing on the one screen where they came to ask the question.
25
+ //
26
+ // * A month whose source rows were incomplete is reported as incomplete.
27
+ // Its total is understated, and presenting it as a clean aggregate hides
28
+ // that.
29
+ // ============================================================================
30
+
31
+ const MONEY_FIELDS = [
32
+ "gross_billings", "refunds", "platform_fee", "net_proceeds",
33
+ "ua_spend", "contribution_margin", "tax", "opex_total", "net_fcf",
34
+ ] as const;
35
+
36
+ export interface FinanceProjectionOptions {
37
+ /** Inclusive month bounds, YYYY-MM. Defaults to the last 13 months present. */
38
+ months?: number;
39
+ locale?: string;
40
+ signal?: AbortSignal;
41
+ /**
42
+ * The project directory. Its workspace owns the finance CSVs, so one
43
+ * tenant can never project another tenant's financials.
44
+ */
45
+ cwd?: string;
46
+ }
47
+
48
+ export interface FinanceProjection {
49
+ module: DashboardModuleBuild;
50
+ /** The latest closed month the projection reports on. */
51
+ closedMonth: string | null;
52
+ /** The open month, when the source carries one. */
53
+ openMonth: string | null;
54
+ }
55
+
56
+ function currency(value: number | null, locale: string, maximumFractionDigits = 0): string | null {
57
+ if (value === null) return null;
58
+ return new Intl.NumberFormat(locale, { style: "currency", currency: "USD", maximumFractionDigits }).format(value);
59
+ }
60
+
61
+ function percent(value: number | null, locale: string): string | null {
62
+ if (value === null) return null;
63
+ return new Intl.NumberFormat(locale, { style: "percent", maximumFractionDigits: 1 }).format(value);
64
+ }
65
+
66
+ function ratio(value: number | null, locale: string): string | null {
67
+ if (value === null) return null;
68
+ return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 2 }).format(value)}x`;
69
+ }
70
+
71
+ function toned(value: string | null, tone?: "positive" | "negative" | "warning" | "neutral"): TableCell {
72
+ return tone ? { value, tone } : value;
73
+ }
74
+
75
+ function num(row: Record<string, unknown> | undefined, key: string): number | null {
76
+ const value = row?.[key];
77
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
78
+ }
79
+
80
+ function text(row: Record<string, unknown> | undefined, key: string): string | null {
81
+ const value = row?.[key];
82
+ return typeof value === "string" && value !== "" ? value : null;
83
+ }
84
+
85
+ /**
86
+ * The P&L bridge for one month.
87
+ *
88
+ * Deductions are emitted as negative deltas so the chart never has to infer a
89
+ * sign, and the two anchors are marked `total` so they are drawn from zero
90
+ * rather than floating on the step before them.
91
+ */
92
+ function pnlWaterfall(row: Record<string, unknown>, month: string, locale: string): WaterfallDataset {
93
+ const step = (key: string, label: string, negate: boolean, kind: "total" | "delta", tone?: "positive" | "negative" | "warning" | "neutral") => {
94
+ const raw = num(row, key);
95
+ const value = raw === null ? null : negate ? -raw : raw;
96
+ return { label, value, display: currency(value, locale), kind, ...(tone ? { tone } : {}) };
97
+ };
98
+ return {
99
+ label: "利润桥",
100
+ description: "从流水到净自由现金流,逐项拆解",
101
+ coverage: month,
102
+ steps: [
103
+ step("gross_billings", "流水", false, "total"),
104
+ step("refunds", "退款", true, "delta"),
105
+ step("platform_fee", "平台分成", true, "delta"),
106
+ step("tax", "税费", true, "delta"),
107
+ step("ua_spend", "广告投放", true, "delta"),
108
+ step("opex_total", "运营成本", true, "delta"),
109
+ step("net_fcf", "净自由现金流", false, "total", "positive"),
110
+ ],
111
+ };
112
+ }
113
+
114
+ export async function projectPortfolioOverview(options: FinanceProjectionOptions = {}): Promise<FinanceProjection> {
115
+ const locale = options.locale ?? "zh-CN";
116
+ const workspaceRoot = options.cwd ? dirname(await realpath(options.cwd)) : undefined;
117
+ const limit = options.months ?? 24;
118
+ const warnings: string[] = [];
119
+
120
+ const pnl = await runStructuredQuery({
121
+ dataset: "pnl_monthly",
122
+ metrics: [...MONEY_FIELDS, "fcf_margin", "roas_blended"],
123
+ dimensions: ["month", "close_status", "data_completeness"],
124
+ limit,
125
+ }, options.signal, workspaceRoot);
126
+ const months = pnl.rows.slice().sort((left, right) => String(left.month).localeCompare(String(right.month)));
127
+ if (months.length === 0) throw new Error("The finance source contains no P&L months; nothing can be projected.");
128
+
129
+ const closedRows = months.filter((row) => text(row, "close_status") === "closed");
130
+ const openRow = months.find((row) => text(row, "close_status") === "open");
131
+ const latestClosed = closedRows.at(-1);
132
+ const closedMonth = latestClosed ? String(latestClosed.month) : null;
133
+ const priorClosed = closedRows.at(-2);
134
+
135
+ // An incomplete month is named, not silently totalled.
136
+ for (const row of months) {
137
+ if (text(row, "data_completeness") === "partial") {
138
+ warnings.push(`${String(row.month)} 的组合合计不完整:有来源明细缺失,该月总额被低估。`);
139
+ }
140
+ }
141
+ if (openRow) {
142
+ warnings.push(`${String(openRow.month)} 尚未结账,其税费、运营成本与净自由现金流显示为缺失而非零。`);
143
+ }
144
+
145
+ const cash = await runStructuredQuery({
146
+ dataset: "cash_position_monthly",
147
+ metrics: ["closing_cash", "runway_months", "monthly_fixed_outflow"],
148
+ dimensions: ["month"],
149
+ filters: { close_status: "closed" },
150
+ limit,
151
+ }, options.signal, workspaceRoot);
152
+ const latestCash = cash.rows.slice().sort((left, right) => String(left.month).localeCompare(String(right.month))).at(-1);
153
+
154
+ const byApp = await runStructuredQuery({
155
+ dataset: "pnl_by_app_monthly",
156
+ metrics: ["net_proceeds", "ua_spend", "contribution_margin", "app_roas"],
157
+ dimensions: ["app_id", "app_name", "store"],
158
+ ...(closedMonth ? { filters: { month: closedMonth } } : {}),
159
+ sort: { by: "contribution_margin", direction: "desc" },
160
+ limit: 100,
161
+ }, options.signal, workspaceRoot);
162
+
163
+ const opex = await runStructuredQuery({
164
+ dataset: "opex_monthly",
165
+ metrics: ["amount_usd"],
166
+ dimensions: ["category"],
167
+ ...(closedMonth ? { filters: { month: closedMonth, close_status: "closed" } } : {}),
168
+ limit: 20,
169
+ }, options.signal, workspaceRoot);
170
+
171
+ const cohorts = await runStructuredQuery({
172
+ dataset: "cohort_ltv",
173
+ metrics: ["installs", "ua_spend", "cac", "ltv_180", "roas_d180", "ltv_to_cac"],
174
+ dimensions: ["store", "maturity"],
175
+ filters: { maturity: "mature" },
176
+ limit: 20,
177
+ }, options.signal, workspaceRoot);
178
+ if (cohorts.rows.length === 0) {
179
+ warnings.push("尚无成熟 cohort,LTV 与回收期显示为缺失。");
180
+ }
181
+
182
+ const fcf = num(latestClosed, "net_fcf");
183
+ const priorFcf = num(priorClosed, "net_fcf");
184
+ const fcfDelta = fcf !== null && priorFcf !== null && priorFcf !== 0 ? fcf / priorFcf - 1 : null;
185
+
186
+ const widgets: DashboardWidget[] = [
187
+ {
188
+ id: "portfolio-fcf",
189
+ type: "stat",
190
+ span: "quarter",
191
+ dataset: "portfolio-fcf.json",
192
+ data: {
193
+ label: "净自由现金流",
194
+ value: currency(fcf, locale),
195
+ ...(fcf === null ? { missingReason: "最近月份尚未结账。" } : {}),
196
+ ...(fcfDelta !== null
197
+ ? { delta: { direction: fcfDelta >= 0 ? "up" as const : "down" as const, label: `${fcfDelta > 0 ? "+" : ""}${percent(fcfDelta, locale)}`, sentiment: fcfDelta >= 0 ? "positive" as const : "negative" as const } }
198
+ : {}),
199
+ ...(closedMonth ? { footnote: `${closedMonth} 已结账` } : {}),
200
+ },
201
+ },
202
+ {
203
+ id: "portfolio-runway",
204
+ type: "stat",
205
+ span: "quarter",
206
+ dataset: "portfolio-runway.json",
207
+ data: {
208
+ label: "现金 Runway",
209
+ value: num(latestCash, "runway_months") === null ? null : `${num(latestCash, "runway_months")!.toFixed(1)} 月`,
210
+ ...(num(latestCash, "runway_months") === null ? { missingReason: "尚无已结账的现金头寸。" } : {}),
211
+ description: `期末现金 ${currency(num(latestCash, "closing_cash"), locale) ?? "—"}`,
212
+ // Naming the definition on the card stops it being read as
213
+ // burn-based runway, which would be a much shorter number.
214
+ footnote: "口径:期末现金 ÷ 月固定支出(运营成本 + 投放)",
215
+ },
216
+ },
217
+ {
218
+ id: "portfolio-contribution",
219
+ type: "stat",
220
+ span: "quarter",
221
+ dataset: "portfolio-contribution.json",
222
+ data: {
223
+ label: "贡献毛利",
224
+ value: currency(num(latestClosed, "contribution_margin"), locale),
225
+ description: "净收入 − 广告投放",
226
+ footnote: "运营成本为公司级,不摊到 App",
227
+ },
228
+ },
229
+ {
230
+ id: "portfolio-fcf-margin",
231
+ type: "stat",
232
+ span: "quarter",
233
+ dataset: "portfolio-fcf-margin.json",
234
+ data: {
235
+ label: "现金流利润率",
236
+ value: percent(num(latestClosed, "fcf_margin"), locale),
237
+ ...(num(latestClosed, "fcf_margin") === null ? { missingReason: "该月尚未结账。" } : {}),
238
+ ...(num(latestClosed, "fcf_margin") !== null
239
+ ? {
240
+ progress: {
241
+ fraction: Math.max(0, Math.min(1, num(latestClosed, "fcf_margin")!)),
242
+ label: percent(num(latestClosed, "fcf_margin"), locale) ?? "—",
243
+ tone: (num(latestClosed, "fcf_margin")! >= 0.3 ? "positive" : "warning") as "positive" | "warning",
244
+ },
245
+ }
246
+ : {}),
247
+ },
248
+ },
249
+ ];
250
+
251
+ if (latestClosed) {
252
+ widgets.push({
253
+ id: "portfolio-pnl-bridge",
254
+ type: "waterfall",
255
+ span: "full",
256
+ dataset: "portfolio-pnl-bridge.json",
257
+ data: pnlWaterfall(latestClosed, closedMonth ?? "", locale),
258
+ });
259
+ }
260
+
261
+ widgets.push({
262
+ id: "portfolio-fcf-trend",
263
+ type: "timeseries",
264
+ span: "full",
265
+ dataset: "portfolio-fcf-trend.json",
266
+ data: {
267
+ label: "净收入与现金流走势",
268
+ description: "按月,未结账月份留空",
269
+ coverage: `${String(months[0].month)} → ${String(months.at(-1)!.month)}`,
270
+ series: [
271
+ { name: "净收入", points: months.map((row) => ({ x: String(row.month), y: num(row, "net_proceeds") })) },
272
+ { name: "净自由现金流", points: months.map((row) => ({ x: String(row.month), y: num(row, "net_fcf") })) },
273
+ ],
274
+ },
275
+ });
276
+
277
+ if (opex.rows.length > 0) {
278
+ widgets.push({
279
+ id: "portfolio-cost-structure",
280
+ type: "stacked-bar",
281
+ span: "half",
282
+ dataset: "portfolio-cost-structure.json",
283
+ data: {
284
+ label: "成本结构",
285
+ description: closedMonth ? `${closedMonth} 运营成本构成` : "运营成本构成",
286
+ bars: [{
287
+ label: closedMonth ?? "最近已结账月",
288
+ total: currency(num(latestClosed, "opex_total"), locale),
289
+ segments: opex.rows.map((row) => ({
290
+ name: String(row.category),
291
+ value: num(row, "amount_usd"),
292
+ display: currency(num(row, "amount_usd"), locale),
293
+ })),
294
+ }],
295
+ },
296
+ });
297
+ }
298
+
299
+ widgets.push({
300
+ id: "portfolio-unit-economics",
301
+ type: "table",
302
+ span: "half",
303
+ dataset: "portfolio-unit-economics.json",
304
+ data: {
305
+ label: "单位经济(成熟 cohort)",
306
+ description: "仅统计满 180 天的 cohort;未成熟的显示为缺失",
307
+ columns: [
308
+ { key: "store", label: "商店" },
309
+ { key: "installs", label: "安装", align: "right" },
310
+ { key: "cac", label: "CAC", align: "right" },
311
+ { key: "ltv", label: "LTV 180", align: "right" },
312
+ { key: "ltv_cac", label: "LTV/CAC", align: "right" },
313
+ { key: "roas", label: "ROAS 180", align: "right" },
314
+ ],
315
+ rows: cohorts.rows.map((row) => ({
316
+ store: String(row.store),
317
+ installs: num(row, "installs") === null ? null : new Intl.NumberFormat(locale).format(num(row, "installs")!),
318
+ cac: currency(num(row, "cac"), locale, 2),
319
+ ltv: currency(num(row, "ltv_180"), locale, 2),
320
+ ltv_cac: toned(ratio(num(row, "ltv_to_cac"), locale), num(row, "ltv_to_cac") === null ? undefined : num(row, "ltv_to_cac")! >= 1.5 ? "positive" : "warning"),
321
+ roas: ratio(num(row, "roas_d180"), locale),
322
+ })),
323
+ },
324
+ });
325
+
326
+ widgets.push({
327
+ id: "portfolio-by-app",
328
+ type: "table",
329
+ span: "full",
330
+ dataset: "portfolio-by-app.json",
331
+ data: {
332
+ label: "App 贡献",
333
+ description: closedMonth ? `${closedMonth},按贡献毛利排序。运营成本为公司级,故此处不含 FCF。` : "按贡献毛利排序",
334
+ columns: [
335
+ { key: "app", label: "App" },
336
+ { key: "store", label: "商店" },
337
+ { key: "net_proceeds", label: "净收入", align: "right" },
338
+ { key: "ua_spend", label: "投放", align: "right" },
339
+ { key: "contribution", label: "贡献毛利", align: "right" },
340
+ { key: "roas", label: "ROAS", align: "right" },
341
+ ],
342
+ rows: byApp.rows.map((row) => ({
343
+ app: `${text(row, "app_name") ?? String(row.app_id)}(${String(row.app_id)})`,
344
+ store: String(row.store),
345
+ net_proceeds: currency(num(row, "net_proceeds"), locale),
346
+ ua_spend: currency(num(row, "ua_spend"), locale),
347
+ contribution: toned(
348
+ currency(num(row, "contribution_margin"), locale),
349
+ num(row, "contribution_margin") === null ? undefined : num(row, "contribution_margin")! < 0 ? "negative" : "positive",
350
+ ),
351
+ roas: ratio(num(row, "app_roas"), locale),
352
+ })),
353
+ },
354
+ });
355
+
356
+ return {
357
+ module: {
358
+ id: "portfolio-overview",
359
+ title: "组合总览",
360
+ status: "published",
361
+ source: {
362
+ artifact_type: "finance_csv",
363
+ closed_month: closedMonth,
364
+ open_month: openRow ? String(openRow.month) : null,
365
+ month_count: months.length,
366
+ datasets: ["pnl_monthly", "pnl_by_app_monthly", "opex_monthly", "cash_position_monthly", "cohort_ltv"],
367
+ data_as_of: closedMonth,
368
+ },
369
+ warnings,
370
+ widgets,
371
+ },
372
+ closedMonth,
373
+ openMonth: openRow ? String(openRow.month) : null,
374
+ };
375
+ }
@@ -13,6 +13,10 @@ import {
13
13
  transitionStrategyModule,
14
14
  } from "./module-publisher.ts";
15
15
  import { projectForecastModule, projectReviewModule, projectStrategyModule } from "./stage-projector.ts";
16
+ import { projectDecisionLedger } from "./decision-ledger-projector.ts";
17
+ import { projectPortfolioOverview } from "./finance-projector.ts";
18
+ import { projectCalibration } from "./calibration-projector.ts";
19
+ import { listDecisionPackages, readDecisionEvents } from "./decision-package.ts";
16
20
  import type { ApprovedCycleForecast } from "../fpa-artifacts/contracts.ts";
17
21
  import { commitStrategyDecision, createStrategyDecisionRequest, readCommittedStrategyDecision } from "./strategy-decision.ts";
18
22
  import {
@@ -262,6 +266,188 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
262
266
  },
263
267
  });
264
268
 
269
+ pi.registerTool({
270
+ name: "fpa_dashboard_publish_decisions",
271
+ label: "Publish FP&A Decision Ledger Module",
272
+ description: "Preview or publish only the decision-ledger module from committed decision packages and their append-only event logs. Other dashboard modules are preserved.",
273
+ promptSnippet: "Publish the approved decision packages and their execution tracking",
274
+ promptGuidelines: [
275
+ "This tool is called by the main Agent; never add it to a Graph tool allowlist.",
276
+ "Preview first, then publish with the exact preview fingerprint and dashboard revision.",
277
+ "Supply measured spend only from an execution receipt or the actuals source. Never estimate it — an unmeasured decision must stay null, because a zero reads as 'nobody acted on this'.",
278
+ ],
279
+ parameters: Type.Object({
280
+ mode: StringEnum(["preview", "publish"] as const),
281
+ expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
282
+ expected_dashboard_revision: Type.Optional(Type.Union([Type.String({ pattern: "^[a-f0-9]{64}$" }), Type.Null()])),
283
+ dashboard_title: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
284
+ /** Measured spend per decision. Omitted decisions render as unmeasured. */
285
+ spend: Type.Optional(Type.Array(Type.Object({
286
+ decision_id: Type.String({ pattern: "^DEC-\\d{4}-\\d{4}-[a-f0-9]{8}$" }),
287
+ actual_spend_usd: Type.Union([Type.Number(), Type.Null()]),
288
+ progress: Type.Optional(Type.Union([Type.Number({ minimum: 0, maximum: 1 }), Type.Null()])),
289
+ source: Type.Optional(Type.String({ minLength: 1, maxLength: 512 })),
290
+ }, { additionalProperties: false }), { maxItems: 500 })),
291
+ }, { additionalProperties: false }),
292
+ executionMode: "sequential",
293
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
294
+ const packages = await listDecisionPackages(ctx.cwd);
295
+ if (packages.length === 0) throw new Error("No decision packages exist yet; confirm a strategy before publishing the decision ledger.");
296
+ const events: Record<string, Awaited<ReturnType<typeof readDecisionEvents>>> = {};
297
+ for (const pkg of packages) events[pkg.decision_id] = await readDecisionEvents(ctx.cwd, pkg.decision_id);
298
+ const known = new Set(packages.map((pkg) => pkg.decision_id));
299
+ for (const entry of params.spend ?? []) {
300
+ if (!known.has(entry.decision_id)) throw new Error(`spend references unknown decision package ${entry.decision_id}.`);
301
+ }
302
+ const { module, breaches } = projectDecisionLedger({ packages, events, spend: params.spend ?? [] });
303
+ const previewFingerprint = dashboardModuleBuildFingerprint(module);
304
+ const current = await readDashboardModuleManifest(ctx.cwd);
305
+ if (params.mode === "preview") return toolResult({
306
+ status: "ready",
307
+ module_id: module.id,
308
+ preview_fingerprint: previewFingerprint,
309
+ dashboard_revision: current?.dashboardRevision ?? null,
310
+ widget_count: module.widgets.length,
311
+ package_count: packages.length,
312
+ threshold_breaches: breaches,
313
+ warnings: module.warnings ?? [],
314
+ });
315
+ if (params.expected_preview_fingerprint !== previewFingerprint) throw new Error("Decision ledger inputs changed after preview; preview again before publishing.");
316
+ if (params.expected_dashboard_revision === undefined) throw new Error("Publish requires expected_dashboard_revision from preview, including null for a new dashboard.");
317
+ const published = await publishDashboardModule({
318
+ cwd: ctx.cwd,
319
+ module,
320
+ dashboardTitle: params.dashboard_title,
321
+ expectedDashboardRevision: params.expected_dashboard_revision,
322
+ });
323
+ return toolResult({
324
+ status: "published",
325
+ module_id: module.id,
326
+ preview_fingerprint: previewFingerprint,
327
+ dashboard_revision: published.dashboardRevision,
328
+ module_revision: published.moduleRevision,
329
+ threshold_breaches: breaches,
330
+ });
331
+ },
332
+ });
333
+
334
+ pi.registerTool({
335
+ name: "fpa_dashboard_publish_portfolio",
336
+ label: "Publish FP&A Portfolio Overview Module",
337
+ description: "Preview or publish only the portfolio-overview module from the finance datasets (P&L, cash, opex, cohorts). Other dashboard modules are preserved.",
338
+ promptSnippet: "Publish the portfolio financial overview",
339
+ promptGuidelines: [
340
+ "This tool is called by the main Agent; never add it to a Graph tool allowlist.",
341
+ "Preview first, then publish with the exact preview fingerprint and dashboard revision.",
342
+ "Requires FPA_FINANCE_CSV_ROOT to point at the finance data directory.",
343
+ "Report the module's warnings to the user: an unclosed month and an incomplete total are limits on the decision, not formatting details.",
344
+ ],
345
+ parameters: Type.Object({
346
+ mode: StringEnum(["preview", "publish"] as const),
347
+ expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
348
+ expected_dashboard_revision: Type.Optional(Type.Union([Type.String({ pattern: "^[a-f0-9]{64}$" }), Type.Null()])),
349
+ dashboard_title: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
350
+ months: Type.Optional(Type.Integer({ minimum: 2, maximum: 120 })),
351
+ }, { additionalProperties: false }),
352
+ executionMode: "sequential",
353
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
354
+ const projection = await projectPortfolioOverview({
355
+ cwd: ctx.cwd,
356
+ ...(params.months !== undefined ? { months: params.months } : {}),
357
+ ...(signal ? { signal } : {}),
358
+ });
359
+ const previewFingerprint = dashboardModuleBuildFingerprint(projection.module);
360
+ const current = await readDashboardModuleManifest(ctx.cwd);
361
+ if (params.mode === "preview") return toolResult({
362
+ status: "ready",
363
+ module_id: projection.module.id,
364
+ preview_fingerprint: previewFingerprint,
365
+ dashboard_revision: current?.dashboardRevision ?? null,
366
+ widget_count: projection.module.widgets.length,
367
+ closed_month: projection.closedMonth,
368
+ open_month: projection.openMonth,
369
+ warnings: projection.module.warnings ?? [],
370
+ });
371
+ if (params.expected_preview_fingerprint !== previewFingerprint) throw new Error("Portfolio inputs changed after preview; preview again before publishing.");
372
+ if (params.expected_dashboard_revision === undefined) throw new Error("Publish requires expected_dashboard_revision from preview, including null for a new dashboard.");
373
+ const published = await publishDashboardModule({
374
+ cwd: ctx.cwd,
375
+ module: projection.module,
376
+ dashboardTitle: params.dashboard_title,
377
+ expectedDashboardRevision: params.expected_dashboard_revision,
378
+ });
379
+ return toolResult({
380
+ status: "published",
381
+ module_id: projection.module.id,
382
+ preview_fingerprint: previewFingerprint,
383
+ dashboard_revision: published.dashboardRevision,
384
+ module_revision: published.moduleRevision,
385
+ warnings: projection.module.warnings ?? [],
386
+ });
387
+ },
388
+ });
389
+
390
+ pi.registerTool({
391
+ name: "fpa_dashboard_publish_calibration",
392
+ label: "Publish FP&A Calibration Module",
393
+ description: "Preview or publish only the forecast-accuracy module: model error, bias direction, variance attribution, and the baseline corrections detected against it. Other dashboard modules are preserved.",
394
+ promptSnippet: "Publish forecast accuracy and the baseline corrections it implies",
395
+ promptGuidelines: [
396
+ "This tool is called by the main Agent; never add it to a Graph tool allowlist.",
397
+ "Preview first, then publish with the exact preview fingerprint and dashboard revision.",
398
+ "A suggested baseline is a proposal awaiting its approver. Never apply one to a forecast before it is approved, and never describe a pending correction as if it were in force.",
399
+ "Report the bias run and the attribution coverage to the user: a partly-explained variance is a limit on the conclusion, not a rounding detail.",
400
+ ],
401
+ parameters: Type.Object({
402
+ mode: StringEnum(["preview", "publish"] as const),
403
+ metric: Type.Optional(Type.String({ minLength: 1, maxLength: 64 })),
404
+ horizon_months: Type.Optional(Type.Integer({ minimum: 1, maximum: 24 })),
405
+ month: Type.Optional(Type.String({ pattern: "^\\d{4}-\\d{2}$" })),
406
+ expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
407
+ expected_dashboard_revision: Type.Optional(Type.Union([Type.String({ pattern: "^[a-f0-9]{64}$" }), Type.Null()])),
408
+ dashboard_title: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
409
+ }, { additionalProperties: false }),
410
+ executionMode: "sequential",
411
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
412
+ const projection = await projectCalibration({
413
+ cwd: ctx.cwd,
414
+ ...(params.metric !== undefined ? { metric: params.metric } : {}),
415
+ ...(params.horizon_months !== undefined ? { horizonMonths: params.horizon_months } : {}),
416
+ ...(params.month !== undefined ? { month: params.month } : {}),
417
+ ...(signal ? { signal } : {}),
418
+ });
419
+ const previewFingerprint = dashboardModuleBuildFingerprint(projection.module);
420
+ const current = await readDashboardModuleManifest(ctx.cwd);
421
+ if (params.mode === "preview") return toolResult({
422
+ status: "ready",
423
+ module_id: projection.module.id,
424
+ preview_fingerprint: previewFingerprint,
425
+ dashboard_revision: current?.dashboardRevision ?? null,
426
+ widget_count: projection.module.widgets.length,
427
+ mape: projection.mape,
428
+ bias_run: projection.biasRun,
429
+ pending_corrections: projection.pendingCorrections,
430
+ warnings: projection.module.warnings ?? [],
431
+ });
432
+ if (params.expected_preview_fingerprint !== previewFingerprint) throw new Error("Calibration inputs changed after preview; preview again before publishing.");
433
+ if (params.expected_dashboard_revision === undefined) throw new Error("Publish requires expected_dashboard_revision from preview, including null for a new dashboard.");
434
+ const published = await publishDashboardModule({
435
+ cwd: ctx.cwd,
436
+ module: projection.module,
437
+ dashboardTitle: params.dashboard_title,
438
+ expectedDashboardRevision: params.expected_dashboard_revision,
439
+ });
440
+ return toolResult({
441
+ status: "published",
442
+ module_id: projection.module.id,
443
+ preview_fingerprint: previewFingerprint,
444
+ dashboard_revision: published.dashboardRevision,
445
+ module_revision: published.moduleRevision,
446
+ warnings: projection.module.warnings ?? [],
447
+ });
448
+ },
449
+ });
450
+
265
451
  pi.registerTool({
266
452
  name: "fpa_dashboard_status",
267
453
  label: "FP&A Dashboard Status",
@@ -52,11 +52,36 @@ export interface GroupedTableDataset {
52
52
  }>;
53
53
  }
54
54
 
55
+ export interface WaterfallDataset {
56
+ label: string;
57
+ description?: string;
58
+ coverage?: string;
59
+ steps: Array<{
60
+ label: string;
61
+ value: number | null;
62
+ display: string | null;
63
+ kind?: "total" | "delta";
64
+ tone?: "positive" | "negative" | "warning" | "neutral";
65
+ }>;
66
+ }
67
+
68
+ export interface StackedBarDataset {
69
+ label: string;
70
+ description?: string;
71
+ bars: Array<{
72
+ label: string;
73
+ total: string | null;
74
+ segments: Array<{ name: string; value: number | null; display: string | null }>;
75
+ }>;
76
+ }
77
+
55
78
  export type DashboardWidget =
56
79
  | { id: string; type: "stat"; span: "quarter" | "half" | "full"; dataset: string; data: StatDataset }
57
80
  | { id: string; type: "timeseries"; span: "quarter" | "half" | "full"; dataset: string; data: TimeseriesDataset }
58
81
  | { id: string; type: "table"; span: "quarter" | "half" | "full"; dataset: string; data: TableDataset }
59
- | { id: string; type: "grouped-table"; span: "quarter" | "half" | "full"; dataset: string; data: GroupedTableDataset };
82
+ | { id: string; type: "grouped-table"; span: "quarter" | "half" | "full"; dataset: string; data: GroupedTableDataset }
83
+ | { id: string; type: "waterfall"; span: "quarter" | "half" | "full"; dataset: string; data: WaterfallDataset }
84
+ | { id: string; type: "stacked-bar"; span: "quarter" | "half" | "full"; dataset: string; data: StackedBarDataset };
60
85
 
61
86
  export interface DashboardBuild {
62
87
  title: string;