@viccydev/pi-fpa 0.7.1 → 0.8.0

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.
package/README.md CHANGED
@@ -148,12 +148,12 @@ pi list
148
148
  团队分发建议使用固定 Git tag:
149
149
 
150
150
  ```bash
151
- pi install git:github.com/linyqh/pi-fpa@v0.7.1
151
+ pi install git:github.com/linyqh/pi-fpa@v0.8.0
152
152
  ```
153
153
 
154
154
  ## 发布到 npm
155
155
 
156
- 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.7.1` 对应 `v0.7.1`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
156
+ 发布动作由 GitHub Release 触发。Release 标签必须严格使用 `v<package.json version>`,例如版本 `0.8.0` 对应 `v0.8.0`。工作流会检出该标签,执行 `npm ci`、`npm test` 和包内容预检,全部通过后发布公开包 `@viccydev/pi-fpa`。普通 Release 发布到 `latest`,Prerelease 发布到 `next`。
157
157
 
158
158
  发布认证使用 npm Trusted Publishing / OIDC,不使用长期 npm Token。npm 包后台的 Trusted Publisher 配置为:
159
159
 
@@ -0,0 +1,45 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+
4
+ import type { DashboardBuild } from "./projector.ts";
5
+ import type { DashboardProjectorProvenance, DashboardProjectorRuntime } from "./provenance.ts";
6
+ import { publishDashboard, resolveDashboardDir } from "./publisher.ts";
7
+ import { publishDashboardModule, readDashboardModuleManifest } from "./module-publisher.ts";
8
+ import type { DashboardActualsSnapshot } from "./source.ts";
9
+
10
+ interface Options {
11
+ cwd: string;
12
+ build: DashboardBuild;
13
+ projector: DashboardProjectorProvenance;
14
+ runtime: DashboardProjectorRuntime;
15
+ actualsSnapshot?: DashboardActualsSnapshot;
16
+ allowBacktestDisplay?: boolean;
17
+ }
18
+
19
+ export async function publishDashboardProjection(options: Options): Promise<{ dashboardDir: string; fingerprint: string; publishedDatasets: number }> {
20
+ const dashboardDir = await resolveDashboardDir(options.cwd);
21
+ let schemaVersion: number | undefined;
22
+ try {
23
+ schemaVersion = (JSON.parse(await readFile(join(dashboardDir, "manifest.json"), "utf8")) as { schemaVersion?: number }).schemaVersion;
24
+ } catch (error) {
25
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
26
+ }
27
+ if (schemaVersion !== 2) return publishDashboard(options);
28
+ if (options.build.source.forecast_role === "backtest" && options.allowBacktestDisplay !== true) {
29
+ throw new Error("A backtest cannot replace dashboard execution evidence without explicit user authorization.");
30
+ }
31
+ const current = await readDashboardModuleManifest(options.cwd);
32
+ const published = await publishDashboardModule({
33
+ cwd: options.cwd,
34
+ dashboardTitle: options.build.title,
35
+ expectedDashboardRevision: current?.dashboardRevision ?? null,
36
+ module: {
37
+ id: "execution-evidence",
38
+ title: "经营执行与校准证据",
39
+ status: "published",
40
+ source: { ...options.build.source, warnings: options.build.warnings, projector: options.projector, runtime: options.runtime },
41
+ widgets: options.build.widgets,
42
+ },
43
+ });
44
+ return { dashboardDir: published.dashboardDir, fingerprint: published.moduleRevision, publishedDatasets: published.publishedDatasets };
45
+ }
@@ -9,7 +9,8 @@ import {
9
9
  type ArtifactRefV2,
10
10
  type ForecastRole,
11
11
  } from "../fpa-artifacts/store.ts";
12
- import { dashboardBuildFingerprint, publishDashboard, resolveDashboardDir } from "./publisher.ts";
12
+ import { dashboardBuildFingerprint, resolveDashboardDir } from "./publisher.ts";
13
+ import { publishDashboardProjection } from "./compat-publisher.ts";
13
14
  import { buildDashboardProjection } from "./service.ts";
14
15
 
15
16
  const EVENT_MAX_BYTES = 64 * 1024;
@@ -723,7 +724,7 @@ async function defaultWorker(event: DashboardRefreshEvent, projectRoot: string,
723
724
  if (event.desired_generation_id && generationId !== event.desired_generation_id) {
724
725
  throw new ObsoleteDashboardRefreshError("Dashboard inputs changed after polling; the stale desired generation must be replanned.");
725
726
  }
726
- await publishDashboard({ cwd: projectRoot, build: result.build, projector: result.projector, runtime: result.runtime, actualsSnapshot: result.actuals });
727
+ await publishDashboardProjection({ cwd: projectRoot, build: result.build, projector: result.projector, runtime: result.runtime, actualsSnapshot: result.actuals });
727
728
  return {
728
729
  generation_id: generationId,
729
730
  data_as_of: result.build.source.data_as_of,
@@ -2,8 +2,19 @@ import { StringEnum } from "@earendil-works/pi-ai";
2
2
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
3
3
  import { Type } from "typebox";
4
4
 
5
- import { readArtifactByRef, type ArtifactRefV2 } from "../fpa-artifacts/store.ts";
6
- import { dashboardBuildFingerprint, publishDashboard } from "./publisher.ts";
5
+ import { readArtifactByRef, readProjectJsonFile, type ArtifactRefV2 } from "../fpa-artifacts/store.ts";
6
+ import { dashboardBuildFingerprint } from "./publisher.ts";
7
+ import { publishDashboardProjection } from "./compat-publisher.ts";
8
+ import {
9
+ dashboardModuleBuildFingerprint,
10
+ publishDashboardModule,
11
+ readDashboardModuleManifest,
12
+ readDashboardModuleSource,
13
+ transitionStrategyModule,
14
+ } from "./module-publisher.ts";
15
+ import { projectForecastModule, projectReviewModule, projectStrategyModule } from "./stage-projector.ts";
16
+ import type { ApprovedCycleForecast } from "../fpa-artifacts/contracts.ts";
17
+ import { commitStrategyDecision, createStrategyDecisionRequest, readCommittedStrategyDecision } from "./strategy-decision.ts";
7
18
  import {
8
19
  enqueueDashboardRefresh,
9
20
  enqueueDashboardRefreshForArtifact,
@@ -28,6 +39,220 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
28
39
  entry_id: Type.String({ pattern: "^[a-f0-9]{64}$" }),
29
40
  body_fingerprint: Type.String({ pattern: "^[a-f0-9]{64}$" }),
30
41
  }, { additionalProperties: false });
42
+ pi.registerTool({
43
+ name: "fpa_dashboard_module_status",
44
+ label: "FP&A Dashboard Module Status",
45
+ description: "Inspect independently published FP&A dashboard modules and their current dashboard revision without changing files.",
46
+ promptSnippet: "Inspect the independently published FP&A dashboard modules",
47
+ parameters: Type.Object({}, { additionalProperties: false }),
48
+ executionMode: "parallel",
49
+ async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
50
+ const manifest = await readDashboardModuleManifest(ctx.cwd);
51
+ return toolResult(manifest ? {
52
+ status: "available",
53
+ dashboard_revision: manifest.dashboardRevision,
54
+ module_order: manifest.moduleOrder,
55
+ modules: Object.fromEntries(Object.entries(manifest.modules).map(([id, module]) => [id, module ? {
56
+ status: module.status,
57
+ revision: module.revision,
58
+ updated_at: module.updatedAt,
59
+ interactive: module.interaction?.kind === "strategy-decision",
60
+ } : null])),
61
+ } : { status: "missing" });
62
+ },
63
+ });
64
+
65
+ pi.registerTool({
66
+ name: "fpa_dashboard_publish_review",
67
+ label: "Publish FP&A Period Review Module",
68
+ description: "Preview or publish only the period-review dashboard module from a project driver_analysis JSON artifact. Other dashboard modules are preserved.",
69
+ promptSnippet: "Publish the completed period analysis as its own dashboard module",
70
+ promptGuidelines: [
71
+ "This tool is called by the main Agent after the analysis Graph returns; never add it to a Graph tool allowlist.",
72
+ "Preview first, then publish with the exact preview fingerprint and dashboard revision.",
73
+ ],
74
+ parameters: Type.Object({
75
+ mode: StringEnum(["preview", "publish"] as const),
76
+ analysis_path: Type.String({ minLength: 1 }),
77
+ expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
78
+ expected_dashboard_revision: Type.Optional(Type.Union([Type.String({ pattern: "^[a-f0-9]{64}$" }), Type.Null()])),
79
+ dashboard_title: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
80
+ }, { additionalProperties: false }),
81
+ executionMode: "sequential",
82
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
83
+ const module = projectReviewModule(await readProjectJsonFile(ctx.cwd, params.analysis_path, "analysis_path"));
84
+ const previewFingerprint = dashboardModuleBuildFingerprint(module);
85
+ const current = await readDashboardModuleManifest(ctx.cwd);
86
+ if (params.mode === "preview") return toolResult({
87
+ status: "ready",
88
+ module_id: module.id,
89
+ preview_fingerprint: previewFingerprint,
90
+ dashboard_revision: current?.dashboardRevision ?? null,
91
+ widget_count: module.widgets.length,
92
+ });
93
+ if (params.expected_preview_fingerprint !== previewFingerprint) throw new Error("Review module inputs changed after preview; preview again before publishing.");
94
+ if (params.expected_dashboard_revision === undefined) throw new Error("Publish requires expected_dashboard_revision from preview, including null for a new dashboard.");
95
+ const published = await publishDashboardModule({
96
+ cwd: ctx.cwd,
97
+ module,
98
+ dashboardTitle: params.dashboard_title,
99
+ expectedDashboardRevision: params.expected_dashboard_revision,
100
+ });
101
+ return toolResult({ status: "published", module_id: module.id, preview_fingerprint: previewFingerprint, dashboard_revision: published.dashboardRevision, module_revision: published.moduleRevision });
102
+ },
103
+ });
104
+
105
+ pi.registerTool({
106
+ name: "fpa_dashboard_publish_strategy",
107
+ label: "Publish FP&A Strategy Module",
108
+ description: "Preview or publish only the reviewed next-strategy module and create a server-side decision action bound to the calling main session. Other modules are preserved.",
109
+ promptSnippet: "Publish the reviewed strategy for human confirmation in the dashboard",
110
+ promptGuidelines: [
111
+ "Call this from the main Agent after the strategy Graph returns; Graph nodes must never publish dashboard modules.",
112
+ "Preview first. Publishing binds the confirmation action to this exact main session and reviewed handoff fingerprint.",
113
+ ],
114
+ parameters: Type.Object({
115
+ mode: StringEnum(["preview", "publish"] as const),
116
+ proposal_path: Type.String({ minLength: 1 }),
117
+ handoff_path: Type.String({ minLength: 1 }),
118
+ expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
119
+ expected_dashboard_revision: Type.Optional(Type.Union([Type.String({ pattern: "^[a-f0-9]{64}$" }), Type.Null()])),
120
+ dashboard_title: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
121
+ }, { additionalProperties: false }),
122
+ executionMode: "sequential",
123
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
124
+ const proposal = await readProjectJsonFile(ctx.cwd, params.proposal_path, "proposal_path");
125
+ const handoff = await readProjectJsonFile(ctx.cwd, params.handoff_path, "handoff_path");
126
+ const module = projectStrategyModule(proposal, handoff);
127
+ const previewFingerprint = dashboardModuleBuildFingerprint(module);
128
+ const current = await readDashboardModuleManifest(ctx.cwd);
129
+ if (params.mode === "preview") return toolResult({
130
+ status: "ready",
131
+ module_id: module.id,
132
+ preview_fingerprint: previewFingerprint,
133
+ dashboard_revision: current?.dashboardRevision ?? null,
134
+ strategy_version: module.source.strategy_version,
135
+ widget_count: module.widgets.length,
136
+ });
137
+ if (params.expected_preview_fingerprint !== previewFingerprint) throw new Error("Strategy module inputs changed after preview; preview again before publishing.");
138
+ if (params.expected_dashboard_revision === undefined) throw new Error("Publish requires expected_dashboard_revision from preview, including null for a new dashboard.");
139
+ const handoffFingerprint = module.source.handoff_fingerprint as string;
140
+ const request = await createStrategyDecisionRequest(ctx.cwd, {
141
+ sessionId: ctx.sessionManager.getSessionId(),
142
+ strategyVersion: module.source.strategy_version as string,
143
+ handoffFingerprint,
144
+ });
145
+ module.interaction = { kind: "strategy-decision", action_id: request.actionId };
146
+ const published = await publishDashboardModule({
147
+ cwd: ctx.cwd,
148
+ module,
149
+ dashboardTitle: params.dashboard_title,
150
+ expectedDashboardRevision: params.expected_dashboard_revision,
151
+ });
152
+ return toolResult({
153
+ status: "awaiting_decision",
154
+ module_id: module.id,
155
+ preview_fingerprint: previewFingerprint,
156
+ dashboard_revision: published.dashboardRevision,
157
+ module_revision: published.moduleRevision,
158
+ action_id: request.actionId,
159
+ });
160
+ },
161
+ });
162
+
163
+ pi.registerTool({
164
+ name: "fpa_dashboard_publish_forecast",
165
+ label: "Publish FP&A Forecast Module",
166
+ description: "Preview or publish only the next-forecast dashboard module from an exact committed approved forecast. Other dashboard modules are preserved.",
167
+ promptSnippet: "Publish the confirmed strategy forecast as its own dashboard module",
168
+ promptGuidelines: [
169
+ "Call this from the main Agent only after fpa_strategy_decision_commit confirmed the exact strategy and the forecast Graph returned an immutable forecast ref.",
170
+ "Never add this tool to a Graph tool allowlist. Preview first and publish with the exact preview fingerprint and dashboard revision.",
171
+ ],
172
+ parameters: Type.Object({
173
+ mode: StringEnum(["preview", "publish"] as const),
174
+ forecast_ref: artifactRefSchema,
175
+ strategy_decision: Type.Object({
176
+ action_id: Type.String({ pattern: "^[a-f0-9]{64}$" }),
177
+ decision_fingerprint: Type.String({ pattern: "^[a-f0-9]{64}$" }),
178
+ }, { additionalProperties: false }),
179
+ expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
180
+ expected_dashboard_revision: Type.Optional(Type.Union([Type.String({ pattern: "^[a-f0-9]{64}$" }), Type.Null()])),
181
+ dashboard_title: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
182
+ }, { additionalProperties: false }),
183
+ executionMode: "sequential",
184
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
185
+ const decision = await readCommittedStrategyDecision(ctx.cwd, params.strategy_decision.action_id, params.strategy_decision.decision_fingerprint);
186
+ if (decision.decision !== "confirm") throw new Error("Forecast publication requires a confirmed strategy decision.");
187
+ const read = await readArtifactByRef(ctx.cwd, params.forecast_ref as ArtifactRefV2);
188
+ if (read.artifact.artifact_type !== "approved_cycle_forecast") throw new Error("Forecast module requires an approved_cycle_forecast artifact ref.");
189
+ const forecast = read.artifact as ApprovedCycleForecast;
190
+ if (forecast.status === "blocked" || !forecast.approval_conditions_satisfied) throw new Error("Forecast module requires a completed forecast whose approval conditions are satisfied.");
191
+ if (forecast.strategy_version !== decision.strategy_version) throw new Error("Forecast strategy_version does not match the confirmed strategy decision.");
192
+ const currentStrategy = (await readDashboardModuleManifest(ctx.cwd))?.modules["next-strategy"];
193
+ if (!currentStrategy || currentStrategy.status !== "approved") throw new Error("Forecast publication requires the current strategy dashboard module to be approved.");
194
+ const strategySource = await readDashboardModuleSource(ctx.cwd, "next-strategy");
195
+ if (strategySource?.strategy_version !== decision.strategy_version || strategySource?.handoff_fingerprint !== decision.handoff_fingerprint) {
196
+ throw new Error("Approved strategy module does not match the confirmed decision lineage.");
197
+ }
198
+ const module = projectForecastModule(forecast, params.forecast_ref as ArtifactRefV2);
199
+ const previewFingerprint = dashboardModuleBuildFingerprint(module);
200
+ const current = await readDashboardModuleManifest(ctx.cwd);
201
+ if (params.mode === "preview") return toolResult({
202
+ status: "ready",
203
+ module_id: module.id,
204
+ preview_fingerprint: previewFingerprint,
205
+ dashboard_revision: current?.dashboardRevision ?? null,
206
+ forecast_version: forecast.forecast_version,
207
+ widget_count: module.widgets.length,
208
+ });
209
+ if (params.expected_preview_fingerprint !== previewFingerprint) throw new Error("Forecast module inputs changed after preview; preview again before publishing.");
210
+ if (params.expected_dashboard_revision === undefined) throw new Error("Publish requires expected_dashboard_revision from preview, including null for a new dashboard.");
211
+ const published = await publishDashboardModule({
212
+ cwd: ctx.cwd,
213
+ module,
214
+ dashboardTitle: params.dashboard_title,
215
+ expectedDashboardRevision: params.expected_dashboard_revision,
216
+ });
217
+ return toolResult({ status: "published", module_id: module.id, preview_fingerprint: previewFingerprint, dashboard_revision: published.dashboardRevision, module_revision: published.moduleRevision });
218
+ },
219
+ });
220
+
221
+ pi.registerTool({
222
+ name: "fpa_strategy_decision_commit",
223
+ label: "Commit FP&A Strategy Decision",
224
+ description: "Commit an idempotent human strategy confirmation or change request from the original main session, then transition the strategy dashboard module.",
225
+ promptSnippet: "Commit the dashboard strategy decision in the original main session",
226
+ parameters: Type.Object({
227
+ action_id: Type.String({ pattern: "^[a-f0-9]{64}$" }),
228
+ decision: StringEnum(["confirm", "request_changes"] as const),
229
+ feedback: Type.Optional(Type.String({ minLength: 1, maxLength: 8000 })),
230
+ }, { additionalProperties: false }),
231
+ executionMode: "sequential",
232
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
233
+ const committed = await commitStrategyDecision(ctx.cwd, {
234
+ actionId: params.action_id,
235
+ sessionId: ctx.sessionManager.getSessionId(),
236
+ decision: params.decision,
237
+ feedback: params.feedback,
238
+ });
239
+ const transitioned = await transitionStrategyModule(ctx.cwd, {
240
+ actionId: params.action_id,
241
+ status: params.decision === "confirm" ? "approved" : "changes_requested",
242
+ });
243
+ return toolResult({
244
+ status: "committed",
245
+ decision: committed.decision,
246
+ decision_fingerprint: committed.decisionFingerprint,
247
+ decision_path: committed.path,
248
+ strategy_version: committed.strategyVersion,
249
+ handoff_fingerprint: committed.handoffFingerprint,
250
+ dashboard_revision: transitioned.dashboardRevision,
251
+ next_stage: params.decision === "confirm" ? "run fpa-forecast-freeze Graph, then publish next-forecast module" : "rerun strategy planning with the exact feedback, then republish next-strategy module",
252
+ });
253
+ },
254
+ });
255
+
31
256
  pi.registerTool({
32
257
  name: "fpa_dashboard_status",
33
258
  label: "FP&A Dashboard Status",
@@ -58,11 +283,13 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
58
283
  label: "Refresh FP&A Dashboard",
59
284
  description:
60
285
  "Build the forecast closed-loop dashboard from a committed approved forecast, optional committed execution receipt, and current read-only FP&A Actuals. " +
61
- "Preview computes and validates without publishing; publish requires the exact preview fingerprint and atomically promotes a content-addressed generation.",
286
+ "Preview computes and validates without publishing; publish requires the exact preview fingerprint and atomically promotes a content-addressed generation. " +
287
+ "A backtest can be displayed only from an exact committed artifact when the user explicitly requests it.",
62
288
  promptSnippet: "Preview or atomically publish the forecast closed-loop FP&A dashboard",
63
289
  promptGuidelines: [
64
290
  "Always call fpa_dashboard_refresh with mode=preview before mode=publish and carry forward the exact preview_fingerprint.",
65
291
  "Never write .fpa-dashboard files with generic file or shell tools; use fpa_dashboard_refresh so calculations, lineage, and atomic publication stay consistent.",
292
+ "Set allow_backtest_display=true only when the user explicitly asks to display or publish an exact committed backtest; never infer this authorization from Graph completion.",
66
293
  ],
67
294
  parameters: Type.Object(
68
295
  {
@@ -70,6 +297,7 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
70
297
  mode: StringEnum(["preview", "publish"] as const),
71
298
  locale: Type.Optional(StringEnum(["zh-CN", "en-US"] as const)),
72
299
  expected_preview_fingerprint: Type.Optional(Type.String({ pattern: "^[a-f0-9]{64}$" })),
300
+ allow_backtest_display: Type.Optional(Type.Boolean()),
73
301
  scope_id: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
74
302
  cycle_id: Type.Optional(Type.String({ minLength: 1, maxLength: 256 })),
75
303
  forecast_ref: Type.Optional(artifactRefSchema),
@@ -86,6 +314,9 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
86
314
  if (hasAnyExactField && exactFields.some((value) => value === undefined)) {
87
315
  throw new Error("scope_id, cycle_id, and forecast_ref must be supplied together; exact refresh never falls back to current artifacts.");
88
316
  }
317
+ if (params.allow_backtest_display && !hasAnyExactField) {
318
+ throw new Error("allow_backtest_display requires an exact scope_id, cycle_id, and forecast_ref.");
319
+ }
89
320
  const exact = hasAnyExactField ? {
90
321
  scopeId: params.scope_id as string,
91
322
  cycleId: params.cycle_id as string,
@@ -96,19 +327,24 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
96
327
  } : undefined;
97
328
  if (exact) {
98
329
  const exactForecast = await readArtifactByRef(ctx.cwd, exact.forecastRef);
99
- if (exactForecast.ledgerContext?.forecast_role === "backtest") {
100
- throw new Error("A backtest forecast cannot be previewed or published as the operating dashboard.");
330
+ const exactRole = exactForecast.ledgerContext?.forecast_role;
331
+ if (exactRole === "backtest" && params.allow_backtest_display !== true) {
332
+ throw new Error("Displaying a backtest requires explicit user authorization through allow_backtest_display=true.");
333
+ }
334
+ if (params.allow_backtest_display === true && exactRole !== "backtest") {
335
+ throw new Error("allow_backtest_display is valid only for an exact committed backtest.");
101
336
  }
102
337
  }
103
338
  const { build, forecast, actuals, sliceKeyMismatch, projector, runtime } = await buildDashboardProjection(ctx.cwd, params.preset, params.locale ?? "zh-CN", exact, signal);
104
- if (build.source.forecast_role === "backtest") {
105
- throw new Error("A backtest forecast cannot be previewed or published as the operating dashboard.");
339
+ if (build.source.forecast_role === "backtest" && params.allow_backtest_display !== true) {
340
+ throw new Error("Displaying a backtest requires explicit user authorization through allow_backtest_display=true.");
106
341
  }
107
342
  const previewFingerprint = dashboardBuildFingerprint(build, projector);
108
343
  const summary = {
109
344
  preset: params.preset,
110
345
  preview_fingerprint: previewFingerprint,
111
346
  forecast_version: forecast.forecast_version,
347
+ forecast_role: build.source.forecast_role,
112
348
  forecast_fingerprint: build.source.forecast_fingerprint,
113
349
  execution_fingerprint: build.source.execution_fingerprint,
114
350
  data_as_of: build.source.data_as_of,
@@ -146,7 +382,14 @@ export default function fpaDashboardExtension(pi: ExtensionAPI): void {
146
382
  if (params.expected_preview_fingerprint !== previewFingerprint) {
147
383
  throw new Error("Dashboard inputs changed after preview; run preview again before publishing.");
148
384
  }
149
- const published = await publishDashboard({ cwd: ctx.cwd, build, projector, runtime, actualsSnapshot: actuals });
385
+ const published = await publishDashboardProjection({
386
+ cwd: ctx.cwd,
387
+ build,
388
+ projector,
389
+ runtime,
390
+ actualsSnapshot: actuals,
391
+ allowBacktestDisplay: params.allow_backtest_display === true,
392
+ });
150
393
  return toolResult({ status: "published", ...summary, dashboard_dir: published.dashboardDir, published_datasets: published.publishedDatasets });
151
394
  },
152
395
  });